Appearance
Voice ai
Generated from
DialerDigital/corecommit0fc18008b3c8— specopenapi/dialer-v1.yamlv0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
AI voice agents: builder + versioned policy, live conversations, transcripts/outcomes, supervisor takeover, the per-tenant AI-dialing kill-switch and today's metrics.
Endpoints
List AI voice agents
http
GET /v1/ai/agentsResponses
200
All AI agents of the tenant.
| Field | Type | Required | Description |
|---|---|---|---|
ai_agents | array of AiAgent | yes | |
ai_agents[].id | string (uuid) | yes | |
ai_agents[].name | string | yes | |
ai_agents[].status | draft · live · paused · retired | yes | |
ai_agents[].voice | object (AiVoice) | no | |
ai_agents[].voice.language | string | yes | BCP-47 (e.g. en-US). |
ai_agents[].voice.voice_id | string | yes | Provider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it. |
ai_agents[].voice.pace | number | yes | 0 = slow … 1 = fast (0.5 ≈ natural). |
ai_agents[].disclosure | object (AiDisclosure) | no | |
ai_agents[].disclosure.text | string | yes | The opening artificial/prerecorded-voice disclosure (content-validated, EN/ES). |
ai_agents[].disclosure.mandated | true | yes | Always true — the disclosure cannot be disabled. |
ai_agents[].disclosure.locked | boolean | no | True once the agent has been live; text is then immutable. |
ai_agents[].policy | object (AiPolicy) | no | Negotiation guardrails the AI is bounded by. |
ai_agents[].policy.max_settlement_pct | integer | no | Max settlement as a percent of balance. |
ai_agents[].policy.min_payment_cents | integer | no | |
ai_agents[].policy.max_plan_months | integer | no | |
ai_agents[].policy.take_payments | none · card_on_file | no | card_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none. |
ai_agents[].policy.prohibited | array of string | no | Disallowed conduct/topics (free-form tags). |
ai_agents[].handoff_rules | array of AiHandoffRule | no | |
ai_agents[].handoff_rules[].id | string (uuid) | null | no | |
ai_agents[].handoff_rules[].trigger | keywords · negative_sentiment · payment_over · human_requested | yes | |
ai_agents[].handoff_rules[].params | object | no | Trigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}. |
ai_agents[].handoff_rules[].action | transfer · offer_human · confirm_human | yes | |
ai_agents[].handoff_rules[].severity | hard · soft | yes | |
ai_agents[].handoff_rules[].locked | boolean | no | human_requested is ALWAYS locked (never removable/editable). |
ai_agents[].policy_version | integer | yes | Monotonic; increments on every versioned change. Transcripts cite it. |
ai_agents[].created_at | string (date-time) | no | |
ai_agents[].updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Create an AI agent (status draft)
http
POST /v1/ai/agentsCreates a draft agent at policy_version: 1. The opening disclosure.text is mandated, content-validated (must self-identify the voice as automated, EN/ES) and becomes IMMUTABLE the first time the agent transitions to live. A human_requested handoff rule is always present and locked. Duplicate name → 409. voice.voice_id, when given, must be one of GET /v1/ai/voices (otherwise 422). Management scope: a ddw_ browser token is 403, and so is a member login (403 role_forbidden, core#2308): the route takes a dd_ key or an account_admin login.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
status | draft · live · paused · retired | no | |
voice | object (AiVoice) | no | |
voice.language | string | yes | BCP-47 (e.g. en-US). |
voice.voice_id | string | yes | Provider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it. |
voice.pace | number | yes | 0 = slow … 1 = fast (0.5 ≈ natural). |
disclosure | object | no | |
disclosure.text | string | no | |
policy | object (AiPolicy) | no | Negotiation guardrails the AI is bounded by. |
policy.max_settlement_pct | integer | no | Max settlement as a percent of balance. |
policy.min_payment_cents | integer | no | |
policy.max_plan_months | integer | no | |
policy.take_payments | none · card_on_file | no | card_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none. |
policy.prohibited | array of string | no | Disallowed conduct/topics (free-form tags). |
handoff_rules | array of AiHandoffRule | no | |
handoff_rules[].id | string (uuid) | null | no | |
handoff_rules[].trigger | keywords · negative_sentiment · payment_over · human_requested | yes | |
handoff_rules[].params | object | no | Trigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}. |
handoff_rules[].action | transfer · offer_human · confirm_human | yes | |
handoff_rules[].severity | hard · soft | yes | |
handoff_rules[].locked | boolean | no | human_requested is ALWAYS locked (never removable/editable). |
json
{
"name": "Ava — Collections Assistant",
"voice": {
"language": "en",
"voice_id": "b24f41fd-00a3-4cd8-992a-a0c9f13f3ef1",
"pace": 0.5
},
"disclosure": {
"text": "This call is from Ava, an automated assistant calling on behalf of Brightpath Recovery Group regarding an account. This call is recorded."
},
"policy": {
"max_settlement_pct": 70,
"min_payment_cents": 5000,
"max_plan_months": 12,
"take_payments": "none",
"prohibited": [
"legal threats",
"third-party disclosure",
"other debts"
]
},
"handoff_rules": [
{
"trigger": "keywords",
"params": {
"keywords": [
"attorney",
"lawyer",
"dispute",
"cease",
"bankruptcy"
]
},
"action": "transfer",
"severity": "hard"
}
]
}Responses
201
Created (status draft).
| Field | Type | Required | Description |
|---|---|---|---|
ai_agent | object (AiAgent) | yes | |
ai_agent.id | string (uuid) | yes | |
ai_agent.name | string | yes | |
ai_agent.status | draft · live · paused · retired | yes | |
ai_agent.voice | object (AiVoice) | no | |
ai_agent.voice.language | string | yes | BCP-47 (e.g. en-US). |
ai_agent.voice.voice_id | string | yes | Provider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it. |
ai_agent.voice.pace | number | yes | 0 = slow … 1 = fast (0.5 ≈ natural). |
ai_agent.disclosure | object (AiDisclosure) | no | |
ai_agent.disclosure.text | string | yes | The opening artificial/prerecorded-voice disclosure (content-validated, EN/ES). |
ai_agent.disclosure.mandated | true | yes | Always true — the disclosure cannot be disabled. |
ai_agent.disclosure.locked | boolean | no | True once the agent has been live; text is then immutable. |
ai_agent.policy | object (AiPolicy) | no | Negotiation guardrails the AI is bounded by. |
ai_agent.policy.max_settlement_pct | integer | no | Max settlement as a percent of balance. |
ai_agent.policy.min_payment_cents | integer | no | |
ai_agent.policy.max_plan_months | integer | no | |
ai_agent.policy.take_payments | none · card_on_file | no | card_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none. |
ai_agent.policy.prohibited | array of string | no | Disallowed conduct/topics (free-form tags). |
ai_agent.handoff_rules | array of AiHandoffRule | no | |
ai_agent.handoff_rules[].id | string (uuid) | null | no | |
ai_agent.handoff_rules[].trigger | keywords · negative_sentiment · payment_over · human_requested | yes | |
ai_agent.handoff_rules[].params | object | no | Trigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}. |
ai_agent.handoff_rules[].action | transfer · offer_human · confirm_human | yes | |
ai_agent.handoff_rules[].severity | hard · soft | yes | |
ai_agent.handoff_rules[].locked | boolean | no | human_requested is ALWAYS locked (never removable/editable). |
ai_agent.policy_version | integer | yes | Monotonic; increments on every versioned change. Transcripts cite it. |
ai_agent.created_at | string (date-time) | no | |
ai_agent.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — AiAgentWriteForbidden
An AI agent write (POST /v1/ai/agents, PATCH or DELETE /v1/ai/agents/{id}) was refused. code role_forbidden: the caller is a member login (the route takes a dd_ key or an account_admin login). code forbidden: the tenant is suspended or the token is a widget token.
json
{
"error": {
"code": "role_forbidden",
"message": "creating an AI agent takes a dd_ key or a dashboard login with the account_admin role"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
json
{
"error": {
"code": "unprocessable",
"message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}The governed AI voice catalog
http
GET /v1/ai/voicesThe voices an AI agent may speak with (core#1539): the ONLY accepted values of voice.voice_id on POST /v1/ai/agents and PATCH /v1/ai/agents/{id}. Versioned data in the control plane, the same for every tenant; adding a voice is a release. language is the primary BCP-47 subtag the voice speaks (en); ?language= filters by it and also takes a full tag (en-US selects en). A language with no voice is an empty list, not an error. Same audience as GET /v1/ai/agents.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
language | query | string | no | Primary language subtag (or a full BCP-47 tag) to filter by. |
Responses
200
The voices, in catalog order.
| Field | Type | Required | Description |
|---|---|---|---|
voices | array of AiVoiceCatalogEntry | yes | |
voices[].voice_id | string | yes | The provider's voice identifier — what an agent stores in voice.voice_id. |
voices[].provider | string | yes | Who synthesizes it (e.g. cartesia). |
voices[].language | string | yes | Primary BCP-47 subtag the voice speaks (e.g. en). |
voices[].label | string | yes | The name an operator sees. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Voice-AI drift alerts, with their evidence and what is being watched
http
GET /v1/ai/agents/driftThe alerts of the drift evaluator, which runs for EVERY tenant every 15 minutes (always on: there is no activation flag, and drift changes no agent, experiment or gate). It reads the hourly buckets (UTC, by the hour the call closed) of ai_conversations + ai_call_metrics per scope, (agent, variant | none) and (model), for five metrics: first_audio_p95_ms, handoff_rate, provider_error_rate, failed_rate and cost_per_call. Two rules judge them: zscore.v1 (the moving band of the insights evaluator: base of the previous 7 days, at least 24 buckets and 30 calls, k = 3.0, only the harmful direction, which is upwards for all five) and budget.v1 (the p95 of the first audio of the bucket above the latency budget, 900 ms).
Every alert is its opened row with the evidence it was written with; a cleared row that cites it makes state cleared and fills resolution. Newest first; page with cursor.
coverage says what is being judged: a series whose last bucket had fewer than 30 calls is insufficient_sample and one without 24 baseline buckets is insufficient_baseline. An empty drift list is NOT "all fine" for those series.
agent_id, variant_id and model are different scopes: model cannot be combined with the other two (400). Needs the tenant scope (a dd_ key or a dashboard login).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
agent_id | query | string (uuid) | no | Only this AI agent's scope. |
variant_id | query | string | no | Only this variant of the agent, or base for the agent without a variant. |
model | query | string | no | Only the scope of every call that used this model. |
metric | query | first_audio_p95_ms · handoff_rate · provider_error_rate · failed_rate · cost_per_call | no | |
state | query | open · cleared | no | |
limit | query | integer | no | Page size, 50 by default and at most 200. |
cursor | query | string | no | The next_cursor of the previous page. |
Responses
200
A page of alerts and the coverage of the watched series.
| Field | Type | Required | Description |
|---|---|---|---|
drift | array of AiDriftEvent | yes | |
drift[].id | string (uuid) | yes | |
drift[].scope | agent · model | yes | |
drift[].ai_agent_id | string,null (uuid) | yes | null in the model scope. |
drift[].variant_id | string,null (uuid) | yes | null for the base agent and in the model scope. |
drift[].model | string,null | yes | Only in the model scope. |
drift[].metric | first_audio_p95_ms · handoff_rate · provider_error_rate · failed_rate · cost_per_call | yes | The five series the drift evaluator watches, each by hourly bucket (UTC, by the hour the call closed): the p95 of the calls' first-audio latency in ms, the share of valid calls handed to a human, the share of measured calls with a provider error, the share of calls that failed, and the mean cost of the priced calls in micro-USD. Every one alerts upwards; an improvement never alerts. |
drift[].rule | zscore.v1 · budget.v1 | yes | |
drift[].direction | up · down | yes | The harmful direction that was crossed. |
drift[].state | open · cleared | yes | |
drift[].window_start | string (date-time) | yes | The hourly bucket that crossed the threshold (inclusive). |
drift[].window_end | string (date-time) | yes | The same bucket (exclusive). |
drift[].observed | number | yes | The bucket value. |
drift[].baseline_mean | number,null | yes | null for budget.v1. |
drift[].baseline_stddev | number,null | yes | Sample standard deviation of the baseline buckets; null for budget.v1. |
drift[].stddev_floor | number,null | yes | The floor applied to the deviation; null for budget.v1. |
drift[].threshold | number | yes | zscore.v1: baseline_mean + k·max(baseline_stddev, stddev_floor); budget.v1: the latency budget in ms. |
drift[].k | number,null | yes | The sensitivity of zscore.v1 (3.0); null for budget.v1. |
drift[].sample_n | integer | yes | Calls in the bucket. |
drift[].baseline_n | integer,null | yes | Baseline buckets used; null for budget.v1. |
drift[].series | array of AiDriftSeriesPoint | yes | The buckets the rule looked at followed by the bucket that crossed, oldest first. |
drift[].series[].t | string (date-time) | yes | Start of the hourly bucket. |
drift[].series[].sample | integer | yes | Calls in the bucket. |
drift[].series[].numerator | number | no | Only for the ratio metrics. |
drift[].series[].denominator | number | no | Only for the ratio metrics. |
drift[].series[].value | number,null | yes | |
drift[].opened_at | string (date-time) | yes | |
drift[].resolution | object,null | yes | The row that cleared it; null while open. |
drift[].resolution.id | string (uuid) | yes | |
drift[].resolution.window_start | string (date-time) | yes | |
drift[].resolution.window_end | string (date-time) | yes | |
drift[].resolution.observed | number | yes | |
drift[].resolution.cleared_at | string (date-time) | yes | |
coverage | array of AiDriftCoverage | yes | |
coverage[].scope | agent · model | yes | |
coverage[].ai_agent_id | string,null (uuid) | yes | |
coverage[].variant_id | string,null (uuid) | yes | |
coverage[].model | string,null | yes | |
coverage[].metric | first_audio_p95_ms · handoff_rate · provider_error_rate · failed_rate · cost_per_call | yes | The five series the drift evaluator watches, each by hourly bucket (UTC, by the hour the call closed): the p95 of the calls' first-audio latency in ms, the share of valid calls handed to a human, the share of measured calls with a provider error, the share of calls that failed, and the mean cost of the priced calls in micro-USD. Every one alerts upwards; an improvement never alerts. |
coverage[].status | ok · insufficient_sample · insufficient_baseline | yes | |
coverage[].window_start | string (date-time) | yes | The last hourly bucket with calls. |
coverage[].sample_n | integer | yes | |
coverage[].baseline_n | integer | yes | |
coverage[].min_sample | integer | yes | |
coverage[].min_baseline | integer | yes | |
next_cursor | string,null | yes | null on the last page. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}Fetch an AI agent
http
GET /v1/ai/agents/{id}Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The AI agent.
| Field | Type | Required | Description |
|---|---|---|---|
ai_agent | object (AiAgent) | yes | |
ai_agent.id | string (uuid) | yes | |
ai_agent.name | string | yes | |
ai_agent.status | draft · live · paused · retired | yes | |
ai_agent.voice | object (AiVoice) | no | |
ai_agent.voice.language | string | yes | BCP-47 (e.g. en-US). |
ai_agent.voice.voice_id | string | yes | Provider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it. |
ai_agent.voice.pace | number | yes | 0 = slow … 1 = fast (0.5 ≈ natural). |
ai_agent.disclosure | object (AiDisclosure) | no | |
ai_agent.disclosure.text | string | yes | The opening artificial/prerecorded-voice disclosure (content-validated, EN/ES). |
ai_agent.disclosure.mandated | true | yes | Always true — the disclosure cannot be disabled. |
ai_agent.disclosure.locked | boolean | no | True once the agent has been live; text is then immutable. |
ai_agent.policy | object (AiPolicy) | no | Negotiation guardrails the AI is bounded by. |
ai_agent.policy.max_settlement_pct | integer | no | Max settlement as a percent of balance. |
ai_agent.policy.min_payment_cents | integer | no | |
ai_agent.policy.max_plan_months | integer | no | |
ai_agent.policy.take_payments | none · card_on_file | no | card_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none. |
ai_agent.policy.prohibited | array of string | no | Disallowed conduct/topics (free-form tags). |
ai_agent.handoff_rules | array of AiHandoffRule | no | |
ai_agent.handoff_rules[].id | string (uuid) | null | no | |
ai_agent.handoff_rules[].trigger | keywords · negative_sentiment · payment_over · human_requested | yes | |
ai_agent.handoff_rules[].params | object | no | Trigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}. |
ai_agent.handoff_rules[].action | transfer · offer_human · confirm_human | yes | |
ai_agent.handoff_rules[].severity | hard · soft | yes | |
ai_agent.handoff_rules[].locked | boolean | no | human_requested is ALWAYS locked (never removable/editable). |
ai_agent.policy_version | integer | yes | Monotonic; increments on every versioned change. Transcripts cite it. |
ai_agent.created_at | string (date-time) | no | |
ai_agent.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Update an AI agent (bumps policy_version)
http
PATCH /v1/ai/agents/{id}Any change to voice / disclosure / policy / handoff_rules / status INCREMENTS policy_version and appends an immutable snapshot (transcripts cite the version in force); a name-only edit does not bump. Constraints: once the agent has been live, disclosure.text is immutable (422); a new voice.voice_id must be in GET /v1/ai/voices (422) — the id the agent ALREADY stores is accepted even when out of the catalog, so a pre-catalog agent stays editable; policy_version is monotonic; the human_requested handoff rule stays locked. status moves within draft|live|paused|retired; moving it to retired while an ACTIVE inbound route of type ai_agent still names the agent is 409 naming the route(s) (core#1547) — disable or re-point them first; pausing is never blocked. Going live needs explicit guards (core#2459): policy.max_settlement_pct, min_payment_cents, max_plan_months, at least one prohibited phrase, and a payment_over handoff rule when take_payments is card_on_file. Server switch ai_agent_live_requires_guards: off, observe (default: 200 plus warnings) or enforce (422 policy_incomplete naming the missing keys: refused are the transition into live and an edit of a live agent that ADDS a missing key; an agent already live without guards is never cut and answers warnings). Management scope: a ddw_ browser token is 403, and so is a member login (403 role_forbidden, core#2308): the route takes a dd_ key or an account_admin login.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
status | draft · live · paused · retired | no | |
voice | object (AiVoice) | no | |
voice.language | string | yes | BCP-47 (e.g. en-US). |
voice.voice_id | string | yes | Provider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it. |
voice.pace | number | yes | 0 = slow … 1 = fast (0.5 ≈ natural). |
disclosure | object | no | |
disclosure.text | string | no | |
policy | object (AiPolicy) | no | Negotiation guardrails the AI is bounded by. |
policy.max_settlement_pct | integer | no | Max settlement as a percent of balance. |
policy.min_payment_cents | integer | no | |
policy.max_plan_months | integer | no | |
policy.take_payments | none · card_on_file | no | card_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none. |
policy.prohibited | array of string | no | Disallowed conduct/topics (free-form tags). |
handoff_rules | array of AiHandoffRule | no | |
handoff_rules[].id | string (uuid) | null | no | |
handoff_rules[].trigger | keywords · negative_sentiment · payment_over · human_requested | yes | |
handoff_rules[].params | object | no | Trigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}. |
handoff_rules[].action | transfer · offer_human · confirm_human | yes | |
handoff_rules[].severity | hard · soft | yes | |
handoff_rules[].locked | boolean | no | human_requested is ALWAYS locked (never removable/editable). |
json
{
"status": "live",
"policy": {
"max_settlement_pct": 65
}
}Responses
200
The updated agent (new policy_version).
| Field | Type | Required | Description |
|---|---|---|---|
ai_agent | object (AiAgent) | yes | |
ai_agent.id | string (uuid) | yes | |
ai_agent.name | string | yes | |
ai_agent.status | draft · live · paused · retired | yes | |
ai_agent.voice | object (AiVoice) | no | |
ai_agent.voice.language | string | yes | BCP-47 (e.g. en-US). |
ai_agent.voice.voice_id | string | yes | Provider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it. |
ai_agent.voice.pace | number | yes | 0 = slow … 1 = fast (0.5 ≈ natural). |
ai_agent.disclosure | object (AiDisclosure) | no | |
ai_agent.disclosure.text | string | yes | The opening artificial/prerecorded-voice disclosure (content-validated, EN/ES). |
ai_agent.disclosure.mandated | true | yes | Always true — the disclosure cannot be disabled. |
ai_agent.disclosure.locked | boolean | no | True once the agent has been live; text is then immutable. |
ai_agent.policy | object (AiPolicy) | no | Negotiation guardrails the AI is bounded by. |
ai_agent.policy.max_settlement_pct | integer | no | Max settlement as a percent of balance. |
ai_agent.policy.min_payment_cents | integer | no | |
ai_agent.policy.max_plan_months | integer | no | |
ai_agent.policy.take_payments | none · card_on_file | no | card_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none. |
ai_agent.policy.prohibited | array of string | no | Disallowed conduct/topics (free-form tags). |
ai_agent.handoff_rules | array of AiHandoffRule | no | |
ai_agent.handoff_rules[].id | string (uuid) | null | no | |
ai_agent.handoff_rules[].trigger | keywords · negative_sentiment · payment_over · human_requested | yes | |
ai_agent.handoff_rules[].params | object | no | Trigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}. |
ai_agent.handoff_rules[].action | transfer · offer_human · confirm_human | yes | |
ai_agent.handoff_rules[].severity | hard · soft | yes | |
ai_agent.handoff_rules[].locked | boolean | no | human_requested is ALWAYS locked (never removable/editable). |
ai_agent.policy_version | integer | yes | Monotonic; increments on every versioned change. Transcripts cite it. |
ai_agent.created_at | string (date-time) | no | |
ai_agent.updated_at | string (date-time) | no | |
warnings | array of object | no | Present in observe and enforce mode when the agent is live with guards missing. |
warnings[].code | policy_incomplete | no | |
warnings[].message | string | no | |
warnings[].missing | array of string | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — AiAgentWriteForbidden
An AI agent write (POST /v1/ai/agents, PATCH or DELETE /v1/ai/agents/{id}) was refused. code role_forbidden: the caller is a member login (the route takes a dd_ key or an account_admin login). code forbidden: the tenant is suspended or the token is a widget token.
json
{
"error": {
"code": "role_forbidden",
"message": "creating an AI agent takes a dd_ key or a dashboard login with the account_admin role"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
json
{
"error": {
"code": "unprocessable",
"message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Retire an AI agent (never deletes)
http
DELETE /v1/ai/agents/{id}Soft-retire (status retired): the row and all its version snapshots survive because live/historical conversations and transcripts reference them. A retired agent cannot dial. 409 while an ACTIVE inbound route of type ai_agent still names the agent (core#1547): the body names the route(s); disable or re-point them first. Management scope: a ddw_ browser token is 403, and so is a member login (403 role_forbidden, core#2308): the route takes a dd_ key or an account_admin login.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The retired agent.
| Field | Type | Required | Description |
|---|---|---|---|
ai_agent | object (AiAgent) | yes | |
ai_agent.id | string (uuid) | yes | |
ai_agent.name | string | yes | |
ai_agent.status | draft · live · paused · retired | yes | |
ai_agent.voice | object (AiVoice) | no | |
ai_agent.voice.language | string | yes | BCP-47 (e.g. en-US). |
ai_agent.voice.voice_id | string | yes | Provider voice identifier. On write it must be a voice_id of GET /v1/ai/voices (422 otherwise); an agent stored before the catalog may still carry an id outside it. |
ai_agent.voice.pace | number | yes | 0 = slow … 1 = fast (0.5 ≈ natural). |
ai_agent.disclosure | object (AiDisclosure) | no | |
ai_agent.disclosure.text | string | yes | The opening artificial/prerecorded-voice disclosure (content-validated, EN/ES). |
ai_agent.disclosure.mandated | true | yes | Always true — the disclosure cannot be disabled. |
ai_agent.disclosure.locked | boolean | no | True once the agent has been live; text is then immutable. |
ai_agent.policy | object (AiPolicy) | no | Negotiation guardrails the AI is bounded by. |
ai_agent.policy.max_settlement_pct | integer | no | Max settlement as a percent of balance. |
ai_agent.policy.min_payment_cents | integer | no | |
ai_agent.policy.max_plan_months | integer | no | |
ai_agent.policy.take_payments | none · card_on_file | no | card_on_file is refused with 422 while the tenant's ai_take_payments_enabled flag is off (the default): nothing collects money yet. An agent already stored with card_on_file is served to the voice runtime as none. |
ai_agent.policy.prohibited | array of string | no | Disallowed conduct/topics (free-form tags). |
ai_agent.handoff_rules | array of AiHandoffRule | no | |
ai_agent.handoff_rules[].id | string (uuid) | null | no | |
ai_agent.handoff_rules[].trigger | keywords · negative_sentiment · payment_over · human_requested | yes | |
ai_agent.handoff_rules[].params | object | no | Trigger config, e.g. {keywords:[...]}, {seconds:20}, {cents:50000}. |
ai_agent.handoff_rules[].action | transfer · offer_human · confirm_human | yes | |
ai_agent.handoff_rules[].severity | hard · soft | yes | |
ai_agent.handoff_rules[].locked | boolean | no | human_requested is ALWAYS locked (never removable/editable). |
ai_agent.policy_version | integer | yes | Monotonic; increments on every versioned change. Transcripts cite it. |
ai_agent.created_at | string (date-time) | no | |
ai_agent.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — AiAgentWriteForbidden
An AI agent write (POST /v1/ai/agents, PATCH or DELETE /v1/ai/agents/{id}) was refused. code role_forbidden: the caller is a member login (the route takes a dd_ key or an account_admin login). code forbidden: the tenant is suspended or the token is a widget token.
json
{
"error": {
"code": "role_forbidden",
"message": "creating an AI agent takes a dd_ key or a dashboard login with the account_admin role"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Sandbox conversation (no live call)
http
POST /v1/ai/agents/{id}/testDrives the agent through a stateless SANDBOX exchange (proxied to the AI runtime) — NO real call, NO ai_conversations row, NO dial. Omit session_id to start; echo it back to continue the same sandbox thread. 503 when the runtime sandbox is unconfigured/unreachable. Management scope: a ddw_ browser token is 403.
Every turn spends the house LLM, so it goes through the inference spend brake (core#2421): message above 4 000 bytes or session_id above 128 bytes is 400; a tenant over its per-window quota of sandbox turns (or over the per-session quota) is 429 with Retry-After; a synthetic demo tenant is 403 (synthetic_tenant); and when the brake refuses the spend (no monthly ceiling, or the ceiling reached) the answer is 503 sandbox_budget_unavailable and the runtime is not called.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
session_id | string (uuid) | no | Omit to start a new sandbox thread; echo to continue one. At most 128 bytes (400 above). |
message | string | yes | At most 4 000 bytes (400 above). |
json
{
"message": "Yes — I can pay $150 on the 27th."
}Responses
200
The agent's sandbox reply.
| Field | Type | Required | Description |
|---|---|---|---|
session_id | string (uuid) | yes | |
reply | string | yes |
json
{
"session_id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
"reply": "I can set that up: $150 on June 27 using the card on file ending 4417. Shall I confirm?"
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — AiSandboxThrottled
This tenant (or this sandbox session) has already used its quota of sandbox turns in the current window (code rate_limited). The runtime was not called. Wait Retry-After seconds and retry.
json
{
"error": {
"code": "rate_limited",
"message": "too many sandbox turns; retry later"
}
}502 — BadGateway
An upstream dependency (the AI runtime sandbox) returned an unexpected/failed response.
json
{
"error": {
"code": "bad_gateway",
"message": "the AI runtime sandbox could not be reached"
}
}503 — SandboxUnavailable
The AI runtime sandbox is unconfigured or unreachable (fail-closed — the runtime may not exist yet), or the inference spend brake refused the turn (code sandbox_budget_unavailable).
json
{
"error": {
"code": "sandbox_unavailable",
"message": "the AI runtime sandbox is not configured or unreachable"
}
}Hear the agent's voice (fixed sample sentence)
http
POST /v1/ai/agents/{id}/voice-previewRenders ONE fixed sentence in the agent's catalogued voice and returns a short-lived signed URL of the audio, so whoever builds the agent hears the voice before a consumer does. The sentence is NOT an input: it is fixed per language (the voice's primary language, en or es), so the render bill is bounded by voices x languages whatever a caller sends. The audio is content-addressed (sha256(text|voice)): the same voice reading the same sentence renders ONCE, for every agent and every tenant; every later request is served from the store (cached: true) with no provider call. POST because a miss spends paid provider time, even though the audio is a pure function of the voice — a prefetch or a crawler never triggers it.
Only a request that has to RENDER spends the per-tenant budget (429, Retry-After); a cached request never does. url is signed at response time and must be played soon (it expires; it is never stored) — request a fresh one rather than keeping it.
Refusals: 404 for an agent that does not exist or belongs to another tenant; 422 when the agent has no voice.voice_id or one outside GET /v1/ai/voices (a live call would use the cell's own voice, so a sample of that id would be a lie) or the voice speaks a language with no sample sentence; 503 with the failing stage in error.reason when the TTS service cannot produce the audio — never an empty audio. Management scope: a ddw_ browser token is 403.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The sample and where to play it.
| Field | Type | Required | Description |
|---|---|---|---|
voice_preview | object (AiVoicePreview) | yes | |
voice_preview.url | string | yes | Signed, short-lived URL of the audio; sign-on-read, never stored. |
voice_preview.voice_id | string | yes | The catalogued voice that read the sentence. |
voice_preview.language | string | yes | Primary BCP-47 subtag of the voice (en or es) — it picks the sentence. |
voice_preview.text | string | yes | The fixed sentence that was read. |
voice_preview.cached | boolean | yes | true when the audio already existed (nothing was rendered for this request). |
json
{
"voice_preview": {
"url": "https://media.example.com/tts/3f9a...c1?X-Signature=...",
"voice_id": "b24f41fd-00a3-4cd8-992a-a0c9f13f3ef1",
"language": "en",
"text": "Hello, this is a preview of the voice your agent will use.",
"cached": true
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
json
{
"error": {
"code": "unprocessable",
"message": "scheduled_at is closer than the minimum notice for a callback (10 minutes unless the tenant's callback_min_notice_seconds says otherwise)"
}
}429 — VoicePreviewThrottled
This tenant has already caused its quota of voice renders in the current window (code rate_limited). The request rendered nothing. Wait Retry-After seconds and retry; a request for a sample that is already rendered is never counted and never refused by this budget.
json
{
"error": {
"code": "rate_limited",
"message": "too many voice samples rendered; retry later"
}
}503 — VoicePreviewUnavailable
The sample could not be produced right now (code voice_preview_unavailable). error.reason names the failing stage and never carries the provider's body, endpoint or credentials: renderer_unconfigured, renderer_unreachable, renderer_http_<status>, renderer_empty_audio, render_failed or store_unavailable. A failed render stores nothing and a retry renders again; after store_unavailable the audio is already stored and a retry is a cache hit.
json
{
"error": {
"code": "voice_preview_unavailable",
"message": "the voice sample could not be produced right now",
"reason": "renderer_http_502"
}
}A/B experiments of an AI agent
http
GET /v1/ai/agents/{id}/experimentsEvery experiment of the agent (any state), newest first, each with its variants in position order.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The agent's experiments.
| Field | Type | Required | Description |
|---|---|---|---|
experiments | array of AiAgentExperiment | yes | |
experiments[].id | string (uuid) | no | |
experiments[].ai_agent_id | string (uuid) | no | |
experiments[].name | string | no | |
experiments[].state | draft · running · ended | no | |
experiments[].primary_metric | ptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_call | no | |
experiments[].started_at | string,null (date-time) | no | |
experiments[].ended_at | string,null (date-time) | no | |
experiments[].ended_reason | string,null | no | Why it ended, when something other than a manual stop ended it; null for a manual stop. |
experiments[].variants | array of AiAgentExperimentVariant | no | |
experiments[].variants[].id | string (uuid) | no | |
experiments[].variants[].position | integer | no | Order the variants are walked in to assign a debt (0-based). |
experiments[].variants[].name | string | no | |
experiments[].variants[].is_control | boolean | no | |
experiments[].variants[].weight | integer | no | |
experiments[].variants[].overrides | object (AiAgentExperimentVariantOverrides) | no | What a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases. |
experiments[].variants[].overrides.voice | object | no | At least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing. |
experiments[].variants[].overrides.prompt_style | string | no | One line (no line break), appended after everything the agent composes; it cannot replace any of it. |
experiments[].variants[].overrides.model | openai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instant | no | A model of the platform's model catalog. |
experiments[].variants[].overrides.llm | object | no | At least one of the two. |
experiments[].created_at | string (date-time) | no | |
experiments[].updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Create an A/B experiment of an AI agent (draft)
http
POST /v1/ai/agents/{id}/experimentsTakes a dd_ key or a dashboard login with the account_admin role, like editing the agent itself (403 role_forbidden for a member login); reads stay open to every tenant-scope token.
Creates a draft experiment of 2 to 5 variants with exactly one control, over a live agent (any other agent is 409). A variant may override only the CLOSED list voice (a voice_id of the voice catalog and/or a pace in [0, 1]), prompt_style (one line of up to 600 characters, appended AFTER everything the agent says by obligation), model (a model of the model catalog) and llm (reasoning_effort, prompt_form). Any other key (disclosure, policy, handoff_rules, tools, max_settlement_pct...) is refused: the legal notice, the negotiation limits and the handoff are not something to test. Every refused field comes back in ONE 422, each with its path in error.details. While the experiment runs, agent-config carries the variant of each call and the conversation records it; an agent with no experiment behaves as before.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
primary_metric | ptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_call | no | |
variants | array of AiAgentExperimentVariantWrite | no | |
variants[].name | string | yes | |
variants[].weight | integer | yes | Share of the traffic, relative to the sum of the weights. |
variants[].is_control | boolean | no | Exactly one variant of the experiment is the control (the base agent, so it sends no overrides). |
variants[].overrides | object (AiAgentExperimentVariantOverrides) | no | What a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases. |
variants[].overrides.voice | object | no | At least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing. |
variants[].overrides.voice.voice_id | string | no | |
variants[].overrides.voice.pace | number | no | |
variants[].overrides.prompt_style | string | no | One line (no line break), appended after everything the agent composes; it cannot replace any of it. |
variants[].overrides.model | openai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instant | no | A model of the platform's model catalog. |
variants[].overrides.llm | object | no | At least one of the two. |
variants[].overrides.llm.reasoning_effort | low · medium · high | no | |
variants[].overrides.llm.prompt_form | compact · full | no |
Responses
201
The draft experiment.
| Field | Type | Required | Description |
|---|---|---|---|
experiment | object (AiAgentExperiment) | yes | |
experiment.id | string (uuid) | no | |
experiment.ai_agent_id | string (uuid) | no | |
experiment.name | string | no | |
experiment.state | draft · running · ended | no | |
experiment.primary_metric | ptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_call | no | |
experiment.started_at | string,null (date-time) | no | |
experiment.ended_at | string,null (date-time) | no | |
experiment.ended_reason | string,null | no | Why it ended, when something other than a manual stop ended it; null for a manual stop. |
experiment.variants | array of AiAgentExperimentVariant | no | |
experiment.variants[].id | string (uuid) | no | |
experiment.variants[].position | integer | no | Order the variants are walked in to assign a debt (0-based). |
experiment.variants[].name | string | no | |
experiment.variants[].is_control | boolean | no | |
experiment.variants[].weight | integer | no | |
experiment.variants[].overrides | object (AiAgentExperimentVariantOverrides) | no | What a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases. |
experiment.variants[].overrides.voice | object | no | At least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing. |
experiment.variants[].overrides.prompt_style | string | no | One line (no line break), appended after everything the agent composes; it cannot replace any of it. |
experiment.variants[].overrides.model | openai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instant | no | A model of the platform's model catalog. |
experiment.variants[].overrides.llm | object | no | At least one of the two. |
experiment.created_at | string (date-time) | no | |
experiment.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — AiAgentExperimentUnprocessable
The body parsed but is not a legal experiment of an AI agent (2 to 5 variants, exactly one control, weights 1..1000, overrides inside the closed list, a voice_id of the voice catalog, a model of the model catalog, a one-line prompt_style of up to 600 characters). Every refused field is in error.details, each with its path (variants[1].overrides.model, or variants[1].overrides for a key outside the list, named in message); nothing was stored.
json
{
"error": {
"code": "unprocessable",
"message": "variants[1].overrides has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm",
"details": [
{
"path": "variants[1].overrides",
"message": "has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm"
}
]
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Fetch an A/B experiment of an AI agent
http
GET /v1/ai/agents/{id}/experiments/{experiment_id}Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the AI agent. |
Responses
200
The experiment with its variants.
| Field | Type | Required | Description |
|---|---|---|---|
experiment | object (AiAgentExperiment) | yes | |
experiment.id | string (uuid) | no | |
experiment.ai_agent_id | string (uuid) | no | |
experiment.name | string | no | |
experiment.state | draft · running · ended | no | |
experiment.primary_metric | ptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_call | no | |
experiment.started_at | string,null (date-time) | no | |
experiment.ended_at | string,null (date-time) | no | |
experiment.ended_reason | string,null | no | Why it ended, when something other than a manual stop ended it; null for a manual stop. |
experiment.variants | array of AiAgentExperimentVariant | no | |
experiment.variants[].id | string (uuid) | no | |
experiment.variants[].position | integer | no | Order the variants are walked in to assign a debt (0-based). |
experiment.variants[].name | string | no | |
experiment.variants[].is_control | boolean | no | |
experiment.variants[].weight | integer | no | |
experiment.variants[].overrides | object (AiAgentExperimentVariantOverrides) | no | What a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases. |
experiment.variants[].overrides.voice | object | no | At least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing. |
experiment.variants[].overrides.prompt_style | string | no | One line (no line break), appended after everything the agent composes; it cannot replace any of it. |
experiment.variants[].overrides.model | openai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instant | no | A model of the platform's model catalog. |
experiment.variants[].overrides.llm | object | no | At least one of the two. |
experiment.created_at | string (date-time) | no | |
experiment.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Edit a draft A/B experiment of an AI agent
http
PATCH /v1/ai/agents/{id}/experiments/{experiment_id}Takes a dd_ key or a dashboard login with the account_admin role, like editing the agent itself (403 role_forbidden for a member login); reads stay open to every tenant-scope token.
name and/or variants (which REPLACES the whole set); primary_metric may only be repeated, never changed. Only a draft can be edited: once it runs (or ended) its variants are frozen and this is 409 (to test another prompt, voice or model, create another experiment).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the AI agent. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
primary_metric | ptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_call | no | |
variants | array of AiAgentExperimentVariantWrite | no | |
variants[].name | string | yes | |
variants[].weight | integer | yes | Share of the traffic, relative to the sum of the weights. |
variants[].is_control | boolean | no | Exactly one variant of the experiment is the control (the base agent, so it sends no overrides). |
variants[].overrides | object (AiAgentExperimentVariantOverrides) | no | What a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases. |
variants[].overrides.voice | object | no | At least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing. |
variants[].overrides.voice.voice_id | string | no | |
variants[].overrides.voice.pace | number | no | |
variants[].overrides.prompt_style | string | no | One line (no line break), appended after everything the agent composes; it cannot replace any of it. |
variants[].overrides.model | openai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instant | no | A model of the platform's model catalog. |
variants[].overrides.llm | object | no | At least one of the two. |
variants[].overrides.llm.reasoning_effort | low · medium · high | no | |
variants[].overrides.llm.prompt_form | compact · full | no |
Responses
200
The edited experiment.
| Field | Type | Required | Description |
|---|---|---|---|
experiment | object (AiAgentExperiment) | yes | |
experiment.id | string (uuid) | no | |
experiment.ai_agent_id | string (uuid) | no | |
experiment.name | string | no | |
experiment.state | draft · running · ended | no | |
experiment.primary_metric | ptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_call | no | |
experiment.started_at | string,null (date-time) | no | |
experiment.ended_at | string,null (date-time) | no | |
experiment.ended_reason | string,null | no | Why it ended, when something other than a manual stop ended it; null for a manual stop. |
experiment.variants | array of AiAgentExperimentVariant | no | |
experiment.variants[].id | string (uuid) | no | |
experiment.variants[].position | integer | no | Order the variants are walked in to assign a debt (0-based). |
experiment.variants[].name | string | no | |
experiment.variants[].is_control | boolean | no | |
experiment.variants[].weight | integer | no | |
experiment.variants[].overrides | object (AiAgentExperimentVariantOverrides) | no | What a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases. |
experiment.variants[].overrides.voice | object | no | At least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing. |
experiment.variants[].overrides.prompt_style | string | no | One line (no line break), appended after everything the agent composes; it cannot replace any of it. |
experiment.variants[].overrides.model | openai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instant | no | A model of the platform's model catalog. |
experiment.variants[].overrides.llm | object | no | At least one of the two. |
experiment.created_at | string (date-time) | no | |
experiment.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — AiAgentExperimentUnprocessable
The body parsed but is not a legal experiment of an AI agent (2 to 5 variants, exactly one control, weights 1..1000, overrides inside the closed list, a voice_id of the voice catalog, a model of the model catalog, a one-line prompt_style of up to 600 characters). Every refused field is in error.details, each with its path (variants[1].overrides.model, or variants[1].overrides for a key outside the list, named in message); nothing was stored.
json
{
"error": {
"code": "unprocessable",
"message": "variants[1].overrides has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm",
"details": [
{
"path": "variants[1].overrides",
"message": "has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm"
}
]
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Delete a draft A/B experiment of an AI agent
http
DELETE /v1/ai/agents/{id}/experiments/{experiment_id}Takes a dd_ key or a dashboard login with the account_admin role, like editing the agent itself (403 role_forbidden for a member login); reads stay open to every tenant-scope token.
Only a draft can be deleted; a running or ended experiment is 409.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the AI agent. |
Responses
204
Deleted.
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Results and honest verdict of an A/B experiment of an AI agent
http
GET /v1/ai/agents/{id}/experiments/{experiment_id}/resultsRecomputed from the ledger on every read (no counters): the conversations stamped with the experiment (ai_conversations), their per-call metrics (ai_call_metrics) and, for the unit of randomization, the debt of the call attempt. A valid conversation is a closed one whose outcome is neither failed nor voicemail; the rates of the primary metrics are over valid conversations, failed_rate over all closed ones. The same experiment read twice gives the same answer.
verdict.status is insufficient_data (some arm is under 100 valid conversations or 10 successes for a rate, under 100 calls with the measure for a mean), no_significant_difference or winner. Rates use the two-proportion z test, means (latency, cost) Welch's test in the normal approximation; two-sided, alpha 0.05, Bonferroni over the challengers. Which way is better depends on the metric: ptp_rate and contained_rate more, handoff_rate, first_audio_p95_ms and cost_per_call less. When the observed split of units deviates from the weights (chi-square, alpha 0.001) allocation_warning is true and no winner is emitted.
A cost with no rate loaded is unpriced (never 0): cost.status says priced, partial or unpriced per variant, and when the primary metric is cost_per_call and some arm has an unpriced call the verdict is insufficient_data with reason: unpriced. The amount of the cost is never published (it is the platform's provider cost; the tenant is billed per minute): each variant carries its consumption in minutes, and a cost_per_call verdict keeps its significance and its winner but not the magnitudes (value, difference, ci_low, ci_high).
safety.breaches is the evidence of the compliance stop: a variant whose compliance_violation_rate is significantly higher than the control's (z, alpha 0.05, at least 100 closed conversations in both arms). Core ends the experiment on its own when that happens (ended_reason says why) and the agent goes back to its base configuration. Reading never changes the experiment.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the AI agent. |
Responses
200
The results of the experiment.
| Field | Type | Required | Description |
|---|---|---|---|
results | object (AiAgentExperimentResults) | yes | |
results.experiment | object | yes | |
results.experiment.id | string (uuid) | no | |
results.experiment.ai_agent_id | string (uuid) | no | |
results.experiment.name | string | no | |
results.experiment.state | draft · running · ended | no | |
results.experiment.primary_metric | ptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_call | no | |
results.experiment.started_at | string,null (date-time) | no | |
results.experiment.ended_at | string,null (date-time) | no | |
results.experiment.ended_reason | string,null | no | Why it ended when something other than a manual stop ended it (the compliance stop writes its evidence here). |
results.variants | array of AiAgentExperimentVariantResults | yes | |
results.variants[].variant_id | string (uuid) | no | |
results.variants[].name | string | no | |
results.variants[].is_control | boolean | no | |
results.variants[].weight | integer | no | |
results.variants[].assigned_units | integer | no | Distinct units with a conversation in this variant: the debt when the call has one, else the call. |
results.variants[].conversations | integer | no | |
results.variants[].closed | integer | no | Conversations with their terminal outcome. |
results.variants[].valid | integer | no | Closed with an outcome other than failed and voicemail; the denominator of the rates. |
results.variants[].failed | integer | no | |
results.variants[].ptp | integer | no | Valid conversations that ended ptp or payment_taken. |
results.variants[].handoffs | integer | no | Valid conversations a human took over. |
results.variants[].contained | integer | no | Valid conversations with no human handoff and an outcome that is neither handoff nor handoff_failed. |
results.variants[].violating | integer | no | Closed conversations with at least one compliance violation. |
results.variants[].ptp_rate | number,null | no | |
results.variants[].contained_rate | number,null | no | |
results.variants[].handoff_rate | number,null | no | Better is LESS. |
results.variants[].failed_rate | number,null | no | Over all closed conversations. |
results.variants[].compliance_violation_rate | number,null | no | Over all closed conversations. |
results.variants[].provider_error_rate | number,null | no | Closed conversations with some provider error over the closed ones that have metrics; null when none has. |
results.variants[].metrics_coverage | object | no | How many closed conversations published metrics. A runtime that does not publish yet shows here, not as a clean 0. |
results.variants[].metrics_coverage.with_metrics | integer | no | |
results.variants[].metrics_coverage.closed | integer | no | |
results.variants[].consumption | object | no | What the closed calls consumed, in the unit the tenant is billed in (minutes). Both fields are null when no closed call has a duration. |
results.variants[].consumption.minutes_total | number,null | no | |
results.variants[].consumption.minutes_per_call | number,null | no | |
results.variants[].first_audio_ms_p95 | object | no | The per-call p95 of the first-audio latency. |
results.variants[].first_audio_ms_p95.n | integer | no | |
results.variants[].first_audio_ms_p95.mean | number,null | no | |
results.variants[].first_audio_ms_p95.variance | number,null | no | |
results.variants[].first_audio_ms_p95.p50_mean | number,null | no | Mean of the per-call p50. |
results.variants[].first_audio_ms_p95.p95 | number,null | no | 95th percentile of the per-call p95. |
results.variants[].cost | object | no | Whether the provider cost of the calls is known. The amount is never published: it is the platform's cost, not a price (the tenant is billed per minute). |
results.variants[].cost.status | priced · partial · unpriced | no | unpriced when no call with metrics has a frozen cost: it is never shown as 0. |
results.variants[].cost.n | integer | no | Calls with a frozen cost. |
results.variants[].cost.unpriced_calls | integer | no | |
results.unattributed_conversations | integer | yes | Conversations stamped with the experiment but with a variant it does not have. Non-zero means the stamp and the experiment disagree. |
results.verdict | object (AiAgentExperimentVerdict) | yes | |
results.verdict.status | insufficient_data · no_significant_difference · winner | yes | |
results.verdict.reason | unpriced · null | yes | Why a verdict that could be read is insufficient_data for a reason other than sample size. |
results.verdict.winner | null | object | yes | |
results.verdict.withheld_by | null | allocation_warning | yes | |
results.verdict.comparisons | array of object | yes | One entry per challenger; empty while the sample floor is not met. |
results.verdict.comparisons[].variant_id | string (uuid) | no | |
results.verdict.comparisons[].value | number | no | The challenger's rate or mean. Absent when the primary metric is cost_per_call. |
results.verdict.comparisons[].difference | number | no | Challenger minus control. Absent when the primary metric is cost_per_call. |
results.verdict.comparisons[].z | number,null | no | Null when both arms have no variance and different means. |
results.verdict.comparisons[].p_value | number | no | |
results.verdict.comparisons[].adjusted_p_value | number | no | |
results.verdict.comparisons[].ci_low | number | no | Absent when the primary metric is cost_per_call. |
results.verdict.comparisons[].ci_high | number | no | Absent when the primary metric is cost_per_call. |
results.verdict.comparisons[].significant | boolean | no | |
results.verdict.sample_floor | object | yes | |
results.verdict.sample_floor.min_n | integer | no | |
results.verdict.sample_floor.min_successes | integer,null | no | Null for a mean. |
results.verdict.sample_floor.met | boolean | no | |
results.allocation_warning | boolean | yes | The observed split of assigned units deviates from the weights (chi-square, alpha 0.001). A winner is never emitted while it is true. |
results.allocation | object | yes | |
results.allocation.chi_square | number | no | |
results.allocation.degrees_of_freedom | integer | no | |
results.allocation.p_value | number | no | |
results.allocation.warning | boolean | no | |
results.safety | object | yes | |
results.safety.breaches | array of object | yes | Variants whose compliance violation rate is significantly higher than the control's. Empty when there is none or when an arm has fewer than 100 closed conversations. |
results.safety.breaches[].variant_id | string (uuid) | no | |
results.safety.breaches[].name | string | no | |
results.safety.breaches[].control_rate | number,null | no | |
results.safety.breaches[].rate | number,null | no | |
results.safety.breaches[].control_closed | integer | no | |
results.safety.breaches[].closed | integer | no | |
results.safety.breaches[].z | number,null | no | |
results.safety.breaches[].p_value | number | no | |
results.safety.breaches[].diff | number | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Start an A/B experiment of an AI agent
http
POST /v1/ai/agents/{id}/experiments/{experiment_id}/startTakes a dd_ key or a dashboard login with the account_admin role, like editing the agent itself (403 role_forbidden for a member login); reads stay open to every tenant-scope token.
draft → running. The agent must be live and the variants are validated again against the voice and model catalogs as they are now (422 with details if a model left the catalog since the draft was written). An agent with another experiment running, an agent that is not live, or an experiment that is not a draft, is 409.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the AI agent. |
Responses
200
The experiment, now running.
| Field | Type | Required | Description |
|---|---|---|---|
experiment | object (AiAgentExperiment) | yes | |
experiment.id | string (uuid) | no | |
experiment.ai_agent_id | string (uuid) | no | |
experiment.name | string | no | |
experiment.state | draft · running · ended | no | |
experiment.primary_metric | ptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_call | no | |
experiment.started_at | string,null (date-time) | no | |
experiment.ended_at | string,null (date-time) | no | |
experiment.ended_reason | string,null | no | Why it ended, when something other than a manual stop ended it; null for a manual stop. |
experiment.variants | array of AiAgentExperimentVariant | no | |
experiment.variants[].id | string (uuid) | no | |
experiment.variants[].position | integer | no | Order the variants are walked in to assign a debt (0-based). |
experiment.variants[].name | string | no | |
experiment.variants[].is_control | boolean | no | |
experiment.variants[].weight | integer | no | |
experiment.variants[].overrides | object (AiAgentExperimentVariantOverrides) | no | What a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases. |
experiment.variants[].overrides.voice | object | no | At least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing. |
experiment.variants[].overrides.prompt_style | string | no | One line (no line break), appended after everything the agent composes; it cannot replace any of it. |
experiment.variants[].overrides.model | openai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instant | no | A model of the platform's model catalog. |
experiment.variants[].overrides.llm | object | no | At least one of the two. |
experiment.created_at | string (date-time) | no | |
experiment.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — AiAgentExperimentUnprocessable
The body parsed but is not a legal experiment of an AI agent (2 to 5 variants, exactly one control, weights 1..1000, overrides inside the closed list, a voice_id of the voice catalog, a model of the model catalog, a one-line prompt_style of up to 600 characters). Every refused field is in error.details, each with its path (variants[1].overrides.model, or variants[1].overrides for a key outside the list, named in message); nothing was stored.
json
{
"error": {
"code": "unprocessable",
"message": "variants[1].overrides has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm",
"details": [
{
"path": "variants[1].overrides",
"message": "has keys outside the closed list: disclosure; allowed: voice, prompt_style, model, llm"
}
]
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}End an A/B experiment of an AI agent
http
POST /v1/ai/agents/{id}/experiments/{experiment_id}/stopTakes a dd_ key or a dashboard login with the account_admin role, like editing the agent itself (403 role_forbidden for a member login); reads stay open to every tenant-scope token.
running → ended (terminal); the agent goes back to being answered as the base agent. Anything else → 409.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
experiment_id | path | string (uuid) | yes | Id of an A/B experiment of the AI agent. |
Responses
200
The experiment, now ended.
| Field | Type | Required | Description |
|---|---|---|---|
experiment | object (AiAgentExperiment) | yes | |
experiment.id | string (uuid) | no | |
experiment.ai_agent_id | string (uuid) | no | |
experiment.name | string | no | |
experiment.state | draft · running · ended | no | |
experiment.primary_metric | ptp_rate · contained_rate · handoff_rate · first_audio_p95_ms · cost_per_call | no | |
experiment.started_at | string,null (date-time) | no | |
experiment.ended_at | string,null (date-time) | no | |
experiment.ended_reason | string,null | no | Why it ended, when something other than a manual stop ended it; null for a manual stop. |
experiment.variants | array of AiAgentExperimentVariant | no | |
experiment.variants[].id | string (uuid) | no | |
experiment.variants[].position | integer | no | Order the variants are walked in to assign a debt (0-based). |
experiment.variants[].name | string | no | |
experiment.variants[].is_control | boolean | no | |
experiment.variants[].weight | integer | no | |
experiment.variants[].overrides | object (AiAgentExperimentVariantOverrides) | no | What a variant changes about its AI agent: a CLOSED list. Any other key is refused with 422 (never ignored). Nothing legal is in the list: not the disclosure, the spoken opt-out, the handoff rules, the tools, the negotiation limits nor the prohibited phrases. |
experiment.variants[].overrides.voice | object | no | At least one of the two. voice_id must be in the voice catalog (GET /v1/ai/voices); the disclosure is pre-rendered with the voice of EACH variant before dialing. |
experiment.variants[].overrides.prompt_style | string | no | One line (no line break), appended after everything the agent composes; it cannot replace any of it. |
experiment.variants[].overrides.model | openai/gpt-oss-20b · openai/gpt-oss-120b · llama-3.3-70b-versatile · llama-3.1-8b-instant | no | A model of the platform's model catalog. |
experiment.variants[].overrides.llm | object | no | At least one of the two. |
experiment.created_at | string (date-time) | no | |
experiment.updated_at | string (date-time) | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}List AI conversations (live feed or history)
http
GET /v1/ai/conversationsactive=true returns the LIVE floor (conversations still in progress), newest first, UNPAGINATED. Without it, returns closed conversations, keyset-paginated (from/to bound started_at).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
active | query | boolean | no | true → only in-progress conversations (the live feed). |
agent_id | query | string (uuid) | no | Restrict to conversations handled by this AI agent. |
from | query | string (date-time) | no | RFC 3339 lower bound (endpoint-specific field; default trailing 30 UTC days where noted). |
to | query | string (date-time) | no | RFC 3339 upper bound. |
limit | query | integer | no | Page size (default 100, max 1000). |
cursor | query | string | no | Opaque keyset cursor from the previous page's next_cursor. |
Responses
200
A page/feed of conversations.
| Field | Type | Required | Description |
|---|---|---|---|
ai_conversations | array of AiConversationListItem | yes | |
ai_conversations[].id | string (uuid) | yes | |
ai_conversations[].call_id | string | null | no | The call's FreeSWITCH uuid. |
ai_conversations[].ai_agent_id | string (uuid) | null | no | |
ai_conversations[].account_ref | string | null | no | |
ai_conversations[].us_state | string | null | no | |
ai_conversations[].phase | disclosure · verification · negotiation · payment_setup · wrapup | yes | |
ai_conversations[].duration_secs | integer | null | no | |
ai_conversations[].sentiment | calm · positive · frustrated · negative | null | no | |
ai_conversations[].handoff_suggested | boolean | no | |
ai_conversations[].started_at | string (date-time) | no | |
ai_conversations[].ended_at | string (date-time) | null | no | |
next_cursor | string | null | yes | Opaque keyset cursor; null on the last page and for the active=true feed. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Fetch a conversation with transcript and outcome
http
GET /v1/ai/conversations/{id}PII (core#2460, ADR #240). Each transcript text comes out with the closed PII catalog redacted (a payment card number, an SSN, a labelled bank account number or a PIN/password), keeping at most the last four digits. include_pii=true asks for the original: it takes a dd_ key or a dashboard login with the account_admin role (403 role_forbidden otherwise, never a silently redacted answer) and is audited.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
include_pii | query | boolean | no | true returns the transcript WITHOUT PII redaction. Requires a dd_ key or an account_admin login: 403 role_forbidden for anyone else. Absent or false ⇒ redacted. Anything that is not a boolean ⇒ 400. |
Responses
200
The conversation, its full transcript and its outcome.
| Field | Type | Required | Description |
|---|---|---|---|
ai_conversation | any | yes |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Supervisor takeover of a live AI conversation
http
POST /v1/ai/conversations/{id}/takeoverTransfers the debtor leg from the AI to the supervisor's REGISTERED endpoint and tears down the orphaned AI leg — the SAME evidence-first mechanic as POST /v1/supervision/calls/{call_id}/takeover (a durable supervision_actions row is written BEFORE any switch command). The {id} is the CONVERSATION id; the server resolves its call_id and drives Dialer.Supervision. A conversation with no live call (sandbox/never-dialed) is 409. session_id in the result is null (no eavesdrop leg). Same WHO gates as the supervision operations: supervisor_ext must carry role supervisor|admin (ADR #116), and the principal must be allowed to act as that agent (ADR #135: a seated ddu_ only as its own seat — 403 forbidden; a seatless ddu_ naming a third party only as account_admin once ddu_author_role_enforced is on — 403 role_forbidden); every refusal is evidenced as refused.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
supervisor_ext | string | yes | Must be an ACTIVE roster extension of THIS tenant with role supervisor or admin (ADR #116; 403 role_forbidden otherwise), and one the principal may act as (ADR #135; a seated ddu_ only its own seat, 403 forbidden); their checked-in device receives the supervision leg (answer within 20 s). |
json
{
"supervisor_ext": "2000"
}Responses
200
Debtor leg transferred to the supervisor (audit row durable).
| Field | Type | Required | Description |
|---|---|---|---|
supervision | object (SupervisionResult) | yes | |
supervision.action | listen · whisper · barge · takeover | no | |
supervision.call_id | string | no | |
supervision.supervisor_ext | string | no | |
supervision.session_id | string | null | no | Supervisor leg uuid; null for takeover (no eavesdrop leg). |
supervision.audit_id | string (uuid) | no | The durable supervision_actions row. |
supervision.to_number | string | no | Masked last-4 (***0184) — the full E.164 never crosses this surface. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
json
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — SupervisionRoleForbidden
The caller may not supervise with that supervisor_ext (ADR #116, core#928; ADR #135, core#1037 phase 3). code role_forbidden: the extension resolves to an active agent whose role is not supervisor or admin — or a ddu_ login without a linked seat named somebody else's extension without the account_admin role (once ddu_author_role_enforced is on). codeforbidden: a principal bound to ONE agent — a ddw_ token, or a ddu_ login linked to a seat — named an extension that is not its own. Either way the attempt IS evidenced — a supervision_actions row with result: refused — and ZERO switch commands ran. Per tenant, supervision_role_enforced = false turns the extension's role check off (the identity binding and the principal rule stay).
role_forbidden
json
{
"error": {
"code": "role_forbidden",
"message": "supervisor_ext must belong to an agent with role supervisor or admin"
}
}forbidden
json
{
"error": {
"code": "forbidden",
"message": "supervisor_ext must be the agent bound to this token"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
json
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
json
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}502 — SupervisionFailed
The switch rejected the supervision command (call already ended / supervisor device not registered).
json
{
"error": {
"code": "supervision_failed",
"message": "the switch rejected the supervision command"
}
}503 — SupervisionUnavailable
code audit_unavailable: the evidence row could not be written so NO switch command was sent (evidence-first, fail-closed). codeesl_unavailable: switch control link down. Both retryable.
audit_unavailable
json
{
"error": {
"code": "audit_unavailable",
"message": "supervision audit unavailable, retry"
}
}esl_unavailable
json
{
"error": {
"code": "esl_unavailable",
"message": "telephony control unavailable, retry"
}
}Kill-switch — pause all AI dialing (tenant-wide)
http
POST /v1/ai/pauseIdempotent. Immediately stops the tenant's AI agents from originating new calls (the "Pause all AI dialing" control; the AIDisclosure gate then blocks every AI origination fail-closed). In-flight conversations continue; use takeover to intervene. The toggle is stamped (paused_at, paused_by). Management scope: a ddw_ browser token is 403.
Role rules (core#2438): a dd_ key or an account_admin login; a member login is 403 role_forbidden (per-tenant knob compliance_config_admin_only, default on).
Responses
200
AI dialing is paused.
| Field | Type | Required | Description |
|---|---|---|---|
ai_dialing | active · paused | yes | |
paused_at | string (date-time) | null | no | |
paused_by | string | null | no |
json
{
"ai_dialing": "paused",
"paused_at": "2026-07-09T17:40:00Z"
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Kill-switch — resume AI dialing (tenant-wide)
http
POST /v1/ai/resumeIdempotent inverse of POST /v1/ai/pause. Management scope (403 for ddw_).
Role rules (core#2438): a dd_ key or an account_admin login; a member login is 403 role_forbidden (per-tenant knob compliance_config_admin_only, default on).
Responses
200
AI dialing is active.
| Field | Type | Required | Description |
|---|---|---|---|
ai_dialing | active · paused | yes | |
paused_at | string (date-time) | null | no | |
paused_by | string | null | no |
json
{
"ai_dialing": "active",
"paused_at": null
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.
json
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}AI-dialing kill-switch state
http
GET /v1/ai/statusThe current kill-switch state (same shape as pause/resume).
Responses
200
The kill-switch state.
| Field | Type | Required | Description |
|---|---|---|---|
ai_dialing | active · paused | yes | |
paused_at | string (date-time) | null | no | |
paused_by | string | null | no |
json
{
"ai_dialing": "active",
"paused_at": null
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Today's AI operating metrics (UTC day)
http
GET /v1/ai/metrics/todayAggregate for the current UTC day off ai_conversations + outcomes + the durable ai_usage meter (the same billable feed as /v1/stats/ai-usage).
Responses
200
Today's metrics.
| Field | Type | Required | Description |
|---|---|---|---|
date | string (date) | no | |
handled | integer | no | AI-handled conversations today, settled or not. |
unsettled | integer | no | Conversations started today with no terminal outcome yet (outcome_type null: still in flight, or their close never reached core). Counted in handled, never in containment_rate. |
containment_rate | number | null | no | Share of today's settled conversations WITH A PERSON that the AI resolved on its own (0..1), rounded to 4 decimals (core#1121, ADR #147). Numerator: outcome_type in resolved, ptp, payment_taken, callback, refused. Denominator: the numerator plus failed, handoff, handoff_failed and dispute. In neither: voicemail (no person on the line; see voicemail_drops) and the unsettled conversations. A conversation handed to a human (the runtime reported handoff.occurred) is never contained, whatever its outcome_type: a ptp or payment_taken followed by a handoff counts in the denominator only (core#1131, ADR #149). null when containment_reason is set. |
containment_reason | no_settled_conversations | null | no | Why containment_rate is null: no_settled_conversations (no conversation with a person settled today — the denominator is 0; absence, not a zero). null when containment_rate is a number. |
avg_handle_secs | integer | no | |
handled_delta_pct | number | null | no | Percent change in handled vs. yesterday up to the SAME time-of-day; null when yesterday had none. |
handoffs | object | no | |
handoffs.count | integer | no | |
handoffs.pct | number | no | |
handoffs_failed | object | no | Conversations settled handoff_failed today: a human was requested and the handoff was not ordered. Counted neither in handoffs nor in containment. |
handoffs_failed.count | integer | no | |
handoffs_failed.pct | number | no | |
ai_minutes | number | no | Billable AI minutes today (from the ai_usage meter). |
billed_cents | integer | no | |
ptp | object | no | |
ptp.count | integer | no | |
ptp.amount_cents | integer | no | |
payments | object | no | |
payments.count | integer | no | |
payments.amount_cents | integer | no | |
voicemail_drops | integer | no | Conversations the runtime classified voicemail (its AMD verdict) today. |
compliance_violations | integer | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}Per-campaign artificial-voice eligibility
http
GET /v1/ai/eligibilityOne entry per campaign that PLAYS artificial voice (AI or TTS/IVR/survey), each with whether it may originate now given the tenant's AI-disclosure capability and the AI-dialing kill-switch. Per-recipient consent (the other half of the artificial-voice gate) is enforced at dial time — not summarized here.
Responses
200
The eligibility list.
| Field | Type | Required | Description |
|---|---|---|---|
eligibility | array of AiEligibilityItem | yes | |
eligibility[].campaign_id | string (uuid) | yes | |
eligibility[].name | string | yes | |
eligibility[].eligible | boolean | yes | |
eligibility[].reason | string | null | no | Why it cannot originate (ai_dialing_paused / disclosure_missing); null when eligible. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.
json
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}429 — ApiKeyRateLimited
This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.
json
{
"error": {
"code": "rate_limited",
"message": "API key request quota exceeded; retry after the interval in Retry-After"
}
}