---
title: "Agents"
description: "Create and manage voice agents over REST: configure prompts, engines, voices, languages, tools, and knowledge, then deploy, duplicate, transfer, or version them."
---

An **agent** is the configuration that drives every ThunderPhone call —
the product tier, the voice, the languages, the system prompt, the
acknowledgement behavior, silence/voicemail handling, the attached
tools and knowledge, and whether the agent is reachable from the web
widget. Phone numbers assign an inbound and/or outbound agent; outbound
calls and mic sessions can target an agent directly.

Agents use a **draft / deploy** workflow: edits made via `PATCH`/`PUT`
are saved as a *draft* and do **not** affect production calls until you
`POST /deploy`. Test calls and mic sessions run against the draft (when
one exists) so you can try changes before shipping them.

For dashboard setup, see [Voice agent settings](/guides/agent-settings). For
prompt-callable actions, see [Built-in tools](/guides/built-in-tools) and
[Transfer calls](/guides/call-transfers).

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/agents` | List agents |
| `POST` | `/v1/agents` | Create an agent |
| `GET` | `/v1/agents/{agent_id}` | Retrieve a single agent |
| `PUT` / `PATCH` | `/v1/agents/{agent_id}` | Save changes to the agent's **draft** |
| `POST` | `/v1/agents/{agent_id}/deploy` | Promote the draft to the deployed config |
| `POST` | `/v1/agents/{agent_id}/discard-draft` | Throw away all pending draft changes |
| `DELETE` | `/v1/agents/{agent_id}` | Delete an agent |
| `POST` | `/v1/agents/{agent_id}/duplicate` | Duplicate an agent |
| `POST` | `/v1/agents/{agent_id}/transfer` | Copy or move an agent to another org |
| `GET` | `/v1/agents/{agent_id}/versions` | List deployed configuration revisions |
| `GET` / `POST` / `DELETE` | `/v1/agents/{agent_id}/intro-clip` | Download, upload or remove the agent's custom [intro clip](#intro-clip) |
| `GET` / `POST` | `/v1/agents/{agent_id}/consent-attestations` | List or record consent-announcement attestations |
| `GET` / `POST` | `/v1/phi-designation-attestation` | Read or record an agent's no-PHI designation attestation |
| `POST` | `/v1/agents/prompt-language-check` | Detect the language of a prompt |
| `POST` | `/v1/agents/translate-prompt` | Translate a prompt into another locale |
| `POST` | `/v1/agents/generate-acknowledgement-prompt` | Auto-generate a Storm-mode acknowledgement prompt |
| `POST` | `/v1/agents/steps/flatten` | Render shared instructions and steps as one prompt |
| `POST` | `/v1/agents/{agent_id}/prompt-analysis` | Check a prompt against the agent's configuration |

Related agent-scoped surfaces documented on their own pages:
[test scenarios & suites](/api-reference/test-scenarios),
[split testing (A/B variants)](/api-reference/split-testing).

## Agent object

```json
{
  "id": 12,
  "name": "Customer Support Agent",
  "prompt": "You are a helpful support agent for Acme Ops…",
  "voice": "john",
  "primary_language": "en",
  "additional_languages": [],
  "native_voice_switching": false,
  "language_switching": "ask_first",
  "product": "spark",
  "thinking_level": "base",
  "background_track": "NONE",
  "acknowledgement_prompt_mode": "auto",
  "acknowledgement_prompt": "",
  "acknowledgement_feedback_mode": "acknowledgement",
  "inbound_speak_order": "agent_first",
  "outbound_speak_order": "caller_first",
  "voicemail_action": "prompt",
  "voicemail_message": "",
  "silence_interval_seconds": 15,
  "silence_max_checkins": 3,
  "silence_checkins_enabled": true,
  "silence_hangup_seconds": 600,
  "max_call_duration_seconds": 3600,
  "ring_duration_seconds": null,
  "connect_tone_enabled": false,
  "typing_sounds_enabled": false,
  "typing_sound": "keys_and_mouse",
  "filler_phrases_enabled": true,
  "tool_start_line": "always",
  "play_sound_asset": "",
  "intro_clip": "none",
  "intro_clip_artifact_path": "",
  "intro_clip_sha256": "",
  "intro_ring_count": 1,
  "speak_uninterruptible_enabled": false,
  "send_email_enabled": false,
  "send_email_recipient": "",
  "calculator_enabled": false,
  "transfer_destinations": [
    {"name": "Billing", "number": "+14155552671", "platform_ok": true},
    {"name": "Assigned rep", "number": "{{rep_phone}}", "platform_ok": null}
  ],
  "warm_transfer_style": "introduce",
  "warm_transfer_briefing": "",
  "consent_announcement_enabled": true,
  "consent_announcement_text": "This call may be recorded…",
  "consent_disabled_reason": "",
  "audio_context_mode": "full",
  "watchdog_enabled": false,
  "additional_audio_context": null,
  "extraction_fields": [
    {
      "key": "insurance_type",
      "type": "enum",
      "description": "Insurance type requested by the caller",
      "options": ["term", "whole"],
      "required": false,
      "ask": true
    }
  ],
  "extraction_blocks_completion": "except_transfers",
  "call_summary_enabled": false,
  "outcome_labels": [],
  "report_instructions": "",
  "timezone": "America/New_York",
  "business_hours": null,
  "reusable_instructions": [],
  "integrations": [],
  "knowledge_bases": [],
  "knowledge_documents": [],
  "mcp_servers": [],
  "tool_collision_warnings": [],
  "widget_enabled": true,
  "split_testing_enabled": false,
  "steps": null,
  "handoffs": [],
  "warnings": [],
  "created_at": "2026-04-20T18:24:10.113Z",
  "updated_at": "2026-04-20T18:24:10.113Z",
  "draft_prompt": null,
  "draft_voice": null,
  "draft_consent_announcement_enabled": null,
  "draft_updated_at": null,
  "has_draft": false
}
```

### Deployed fields

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Server-assigned agent id |
| `name` | string | Human-readable name. 1–255 chars |
| `prompt` | string | The system prompt that governs agent behavior |
| `voice` | string | Voice name from [`GET /v1/voices`](#voices) — e.g. `john`, `megan`. Entitled organizations can also pass [`custom:<public_id>`](/api-reference/custom-voices#use-a-custom-voice) for a ready custom voice, and any agent that does not process PHI can use an [ElevenLabs voice](#elevenlabs-voices) as `elevenlabs:<voice_id>` |
| `primary_language` | string | ISO 639 code, e.g. `en`, `es`. Default `en`. Drives the default TTS locale |
| `additional_languages` | array of string | Extra language codes the agent supports (`es` or BCP-47 `es-ES`) |
| `native_voice_switching` | boolean | For multilingual agents using catalog voices, switch to a native voice for the detected additional language, matched by gender. Returns to the configured voice for the primary language and keeps the current voice if no match is available. Default `false` |
| `language_switching` | string | How a multilingual agent handles the other person (the caller, or the person an outbound call reaches) speaking another of its languages. `ask_first` (default) switches at once when they ask for the language or say a full sentence in it, and asks whether to continue in it after something short, like a "sí". `follow_caller` switches to whatever configured language they speak, even a single word. Applies only when `additional_languages` is set |
| `product` | string | Product tier. One of: `spark`, `bolt`, `storm-base`, `storm-base-with-ack`, `storm-extra`, `storm-extra-with-ack` |
| `thinking_level` | string | `base` or `extra`. Derived from `product` for Storm tiers |
| `background_track` | string | Ambient audio track name, or `"NONE"` for silence. **Never `null`** — a `null` write is coerced to `"NONE"` |
| `acknowledgement_prompt_mode` | string | `auto` (we generate the ack prompt) or `manual` (you supply it) |
| `acknowledgement_prompt` | string | Used when `acknowledgement_prompt_mode="manual"`. Storm-tier only |
| `acknowledgement_feedback_mode` | string | While a Storm agent thinks: `acknowledgement` (spoken filler) or `tick` (ticking sound) |
| `inbound_speak_order` | string | Who talks first on inbound calls. `agent_first` or `caller_first` |
| `outbound_speak_order` | string | Who talks first on outbound calls |
| `voicemail_action` | string | What to do when a voicemail system answers: `prompt` (let the agent decide), `hangup`, or `message` (leave a message) |
| `voicemail_message` | string | TTS template for `voicemail_action="message"`, max 2000 chars. Supports `{agent_name}` and `{org_name}` variables |
| `silence_interval_seconds` | integer | Seconds between "are you still there?" check-ins. 10–120, default 15 |
| `silence_max_checkins` | integer | Check-ins before the agent hangs up. 1–10, default 3 |
| `silence_checkins_enabled` | boolean | Whether the agent proactively checks in during caller silence. Default `true` |
| `silence_hangup_seconds` | integer | Caller-idle seconds before a silent hangup when check-ins are disabled. 60–3600, default 600 |
| `max_call_duration_seconds` | integer or null | Maximum call length in seconds. 60–21600 (1 minute–6 hours); `null` means no per-agent limit. New agents start at your organization's new-agent default (3600, one hour) unless you send a value; agents created before that default keep what they had. Every call ends after 6 hours whatever this says |
| `ring_duration_seconds` | integer or null | Ringing time for outbound and transferred calls, in seconds. 5–120; null uses platform defaults (60s outbound, 45s warm transfer, provider default for cold transfer). Does not affect inbound ringing. |
| `connect_tone_enabled` | boolean | Play a short connection tone before the agent begins. Default `false` |
| `typing_sounds_enabled` | boolean | Play a soft keyboard sound while a custom tool runs. Platform-managed tools do not trigger it. Default `false` |
| `typing_sound` | string | Which typing sound plays when `typing_sounds_enabled` is on: `keys_and_mouse`, `mechanical`, `desktop`, `laptop`, `office`, or `quiet_room`. Default `keys_and_mouse` |
| `filler_phrases_enabled` | boolean | Long-wait updates: on a slow tool or reply, lines such as "Sorry, this is taking a little longer than expected" after the tool-start line. `false` stays quiet after the tool-start line, which `tool_start_line` controls. Default `true` |
| `tool_start_line` | string | Tool-start line: what ThunderPhone says when the agent calls a tool. `always`: something at every tool call (the agent's own words, or a short line written for the call). `only_if_slow`: a short line only if the tool is still running after about 3 seconds. `never`: nothing from ThunderPhone, and the agent is told not to speak at tool calls. The agent's own words always play. New agents default to `always`; agents created before this field existed have `only_if_slow`. Prompt text cannot override it |
| `play_sound_asset` | string | Prompt-callable sound asset: `""` (none), `chime`, `ding`, or `beep` |
| `intro_clip` | string | Prerecorded clip played at the start of inbound phone calls (and Talk browser test calls), before the agent speaks: `none`, `stock_recording_notice` ("This call may be monitored or recorded, for quality assurance purposes."), or `custom` (the clip uploaded with [`POST /v1/agents/{agent_id}/intro-clip`](#intro-clip); plays nothing until one is uploaded). Not played on outbound calls, AI test calls, web widget calls or Realtime API calls. Default `none` |
| `intro_clip_artifact_path` | string | Read-only. Storage path of the uploaded custom clip, `""` when there is none |
| `intro_clip_sha256` | string | Read-only. SHA-256 of the uploaded custom clip, `""` when there is none |
| `intro_ring_count` | integer | US rings played after the intro clip, before the agent speaks. 0–2, default 1. Only used when `intro_clip` is not `none` |
| `speak_uninterruptible_enabled` | boolean | Legacy compatibility field. The `speak_uninterruptible` tool is currently unavailable, so this setting has no runtime effect. Default `false` |
| `send_email_enabled` | boolean | Offer the built-in email tool when `send_email_recipient` is also set. Default `false` |
| `send_email_recipient` | string | Fixed recipient for the built-in email tool. The model supplies only the subject and body and cannot choose or change this address |
| `calculator_enabled` | boolean | Offer the built-in `calculate` tool, which adds up totals and prices exactly. Default `false` |
| `send_sms_enabled` | boolean | Offer the `send_sms` tool when a customer-connected SMS-capable sender is available. Default `false` |
| `sms_from_number` | string | Optional E.164 fallback sender on the organization's connected Twilio or Telnyx account |
| `sms_allowed_recipients` | array of object | Up to 10 owner-configured contacts the agent may text. Each object has a unique E.164 `number` and a unique NFKC-normalized, case-insensitive `label` of 1–60 visible characters. Labels cannot look like phone numbers and are caller-visible public metadata; only labels are shown to the model |
| `sms_allow_other_recipients` | boolean | Allow the model to text any valid E.164 number, including any number a caller dictates. Default `false`. This broad permission is not required for the telephone other party or `sms_allowed_recipients` |
| `transfer_destinations` | array of object | The agent's [transfer numbers](#transfer-numbers): up to 50 `{name, number}` entries, each with a read-only `platform_ok`. The agent transfers only to these, by name; `[]` (the default for new agents) means it can't transfer calls |
| `warm_transfer_style` | string | What happens when a [warm transfer](/guides/call-transfers#before-connecting) is answered. `introduce` (default, **Introduce on the line**): the caller is connected as soon as the person answers, then the agent introduces the caller on the line, which both hear, and drops. `ask_first` (**Ask first**): the caller waits on hold while the agent privately asks the person who answers whether they can take the call; they can decline. On yes the caller is connected and introduced on the line. `whisper` (**Whisper**): the caller waits on hold while the agent privately tells the person who answers who is calling and why, without asking; then the caller is connected and the agent drops with nothing said on the line |
| `warm_transfer_briefing` | string | What the agent tells the person who answers a [warm transfer](/guides/call-transfers#the-briefing) about the caller, as an instruction to the agent, for example `"The caller's name and what they need."`. ≤300 characters; line breaks and repeated spaces are collapsed. `""` (the default) keeps a very short briefing: who is calling and why |
| `sms_followup_enabled` | boolean | Send one SMS from the customer carrier after an eligible completed telephone call. Default `false` |
| `sms_followup_template` | string | Plain-text follow-up template. Supports call variables and extracted fields when present |
| `consent_announcement_enabled` | boolean | Play the configured AI/recording announcement at call start. Default `true`; disabling requires an active [consent attestation](#consent-attestations) where applicable |
| `consent_announcement_text` | string | Announcement played verbatim at call start; must not be blank and is limited to 2000 characters in the dashboard |
| `consent_disabled_reason` | string | Read-only platform-managed status associated with an announcement exception. Clients cannot set it |
| `audio_context_mode` | string | API-only Storm compatibility control: `full` or `reduced` (lower latency, less prior audio). This is separate from `additional_audio_context` |
| `watchdog_enabled` | boolean | Enables live supervision of agent turns (Storm). Default `false` |
| `additional_audio_context` | boolean \| null | Backing field for the dashboard's **Extra audio context** choices: `true` is **On**, `false` is **Off**, and `null` is **Automatic (recommended)**. Automatic uses more context for inbound and browser/WebSocket sessions and only the latest turn for outbound phone calls |
| `extraction_fields` | array | Complete structured-data schema extracted from each retained call transcript. Up to 40 fields. Each item requires a unique lowercase `key`, a `type` (`string`, `number`, `boolean`, `enum`, `date`, or `datetime`), and a 1–500 character `description`. `enum` fields require 2–50 unique `options`; `ask` defaults to `true` and `required` defaults to `false`. Writes replace the complete list. |
| `outcome_labels` | array | How calls with a gradeable conversation are labelled in the [call report](/api-reference/calls#call-report) (dashboard: **Reporting** in the agent builder, where **Suggest from prompt** proposes a set fitted to the agent's job). `[]` (default) uses the platform pair `success` / `failure`, judged as before. Otherwise send 2–12 objects: `key` is required, unique, and matches `[a-z][a-z0-9_]{0,39}`; `label` is required, 1–60 characters after whitespace is collapsed; `description` is optional (default `""`, up to 400 characters) and tells the grader when the label applies; `color` is optional, one of `gray`, `green`, `red`, `amber`, `blue`, `purple`, `pink` or `teal` (default `gray`), and only sets the pill colour. Keeping the keys `success` / `failure` but rewriting a label or description makes them your own definitions. Calls are judged against the labels in effect when they started. A quick hang-up has no configured outcome and shows as `No conversation`. Writes replace the complete list. |
| `report_instructions` | string | Rules for the report's prose fields (one-line summary, call reason, caller-experience note, attention note), for example what must never be mentioned. ≤2,000 characters; `""` uses the platform default. |
| `timezone` | string | IANA time zone, up to 64 characters, such as `America/New_York` (dashboard: **Time zone**). The agent states the date and time, offers times and works out "today" in it, and this agent's business hours in reports use it. Set it for every agent. `""` means automatic: each phone call uses the zone of the number it is on when that number's area code places it, else the zone of the agent's numbers, else the [organization's](/guides/organization-settings#reporting), else US Pacific; reports then use the organization's zone. An agent with `""` lists `timezone_not_set` in `warnings`. A call can override it with its own [`timezone`](/api-reference/outbound-calls). |
| `business_hours` | object \| null | Weekly schedule keyed by `mon` through `sun`, each a list of at most four `{start, end}` windows in 24-hour `HH:MM` with the end later than the start, deciding which of this agent's calls count as after hours. `null` inherits the organization's schedule; `{}` counts every call as business hours. |
| `call_summary_enabled` | boolean | Generate a plain-language summary after each call (dashboard: **Generate a call summary** under **Data to collect**). Delivered as `summary` on [`telephony.complete` / `web.complete`](/webhooks/call-complete) and on the [Call object](/api-reference/calls#call-object). Runs in the post-call extraction step, so `extraction_blocks_completion` decides whether the completion event waits for it; when it does not wait, the summary is only available on the Call object. Requires call recording; not generated for calls too short to summarize. Default `false`. |
| `reusable_instruction_ids` | array | UUIDs of the organization's [reusable instructions](/api-reference/reusable-instructions) attached to this agent. Each attached instruction is appended to the end of the system prompt under a `## <name>` heading at call start, in creation order. Writes replace the complete list; responses embed the attached rows as `reusable_instructions` (the `prompt` field itself is unchanged). |
| `extraction_blocks_completion` | string | When extraction may hold customer completion and legacy webhook delivery: `except_transfers` (default), `always`, or `never`. Billing settlement and reservation release never wait. In a blocking mode, completion normally waits at most the 75-second extraction budget. If the worker process is lost, the production finalization drain (every five minutes) releases a past-deadline completion before starting another extraction attempt; a later success emits `call.data_extracted`. |
| `integrations` | array | Linked [Integration](/api-reference/integrations) objects |
| `knowledge_bases` | array | Attached [knowledge bases](/api-reference/knowledge-bases) (full objects) |
| `knowledge_documents` | array | Individually attached [knowledge documents](/api-reference/knowledge-bases#knowledge-document-object) |
| `mcp_servers` | array | Attached [MCP servers](/api-reference/mcp-servers) (full objects) |
| `widget_enabled` | boolean | Whether the agent is reachable via the embeddable web widget |
| `split_testing_enabled` | boolean | Whether calls are split across [A/B variants](/api-reference/split-testing) |
| `warnings` | array | Read-only [configuration warnings](#configuration-warnings) for the draft the builder and test calls use: tool, handoff and time zone problems. Never blocks a save. Default `[]` |
| `tool_collision_warnings` | array | Deprecated: read `warnings`. The same list without `timezone_not_set`. |
| `steps` | object \| null | Splits the agent into [steps](#steps-and-handoffs). `null` (default) is a single-prompt agent. When set, `prompt` holds the shared instructions that apply in every step |
| `handoffs` | array | Other agents in the organization this agent can [hand the call to](#steps-and-handoffs). Default `[]` |
| `created_at`, `updated_at` | timestamp | ISO 8601 UTC |

<Warning>
  Extracted values are a model's interpretation of the conversation. Each
  non-null value includes the quote, speaker role, and turn index it was based
  on, plus a run-level verification flag. Verify the result before decisions
  involving money, identity, or access.
</Warning>

### Draft fields

Every configurable field above has a `draft_*` mirror in the response
(`draft_name`, `draft_prompt`, `draft_voice`, `draft_primary_language`,
`draft_additional_languages`, `draft_native_voice_switching`, `draft_language_switching`, `draft_product`, `draft_background_track`,
`draft_thinking_level`, `draft_acknowledgement_prompt_mode`,
`draft_acknowledgement_prompt`, `draft_acknowledgement_feedback_mode`,
`draft_inbound_speak_order`, `draft_outbound_speak_order`,
`draft_voicemail_action`, `draft_voicemail_message`,
`draft_silence_interval_seconds`, `draft_silence_max_checkins`,
`draft_silence_checkins_enabled`, `draft_silence_hangup_seconds`, `draft_max_call_duration_seconds`, `draft_ring_duration_seconds`, `draft_connect_tone_enabled`,
`draft_typing_sounds_enabled`, `draft_typing_sound`, `draft_filler_phrases_enabled`, `draft_tool_start_line`, `draft_play_sound_asset`,
`draft_intro_clip`, `draft_intro_clip_artifact_path`, `draft_intro_clip_sha256`, `draft_intro_ring_count`,
`draft_speak_uninterruptible_enabled`, `draft_send_email_enabled`,
`draft_send_email_recipient`, `draft_calculator_enabled`,
`draft_send_sms_enabled`, `draft_sms_from_number`, `draft_sms_allow_other_recipients`,
`draft_sms_allowed_recipients`, `draft_transfer_destinations`,
`draft_warm_transfer_style`, `draft_warm_transfer_briefing`,
`draft_sms_followup_enabled`, `draft_sms_followup_template`,
`draft_consent_announcement_enabled`, `draft_consent_announcement_text`,
`draft_audio_context_mode`, `draft_watchdog_enabled`, `draft_additional_audio_context`,
`draft_extraction_fields`, `draft_extraction_blocks_completion`, `draft_call_summary_enabled`, `draft_outcome_labels`, `draft_report_instructions`, `draft_timezone`, `draft_business_hours`, `draft_reusable_instruction_ids`,
`draft_widget_enabled`, `draft_split_testing_enabled`, `draft_steps`, `draft_handoffs`), plus draft id
lists for attachments (`draft_integration_ids`,
`draft_knowledge_base_ids`, `draft_knowledge_document_ids`,
`draft_mcp_server_ids`) and two read-only markers:

| Field | Type | Description |
|-------|------|-------------|
| `draft_<field>` | same as field \| null | `null` = no pending change for that field; non-null = the value staged for the next deploy |
| `draft_updated_at` | timestamp \| null | When the draft was last edited |
| `has_draft` | boolean | True when any draft field is set |

`draft_max_call_duration_seconds` is read-only: `null` means unchanged, and
`0` means a pending removal of the cap. Stage removal by sending
`"max_call_duration_seconds": null`; deployment restores the live value to `null`.

`draft_ring_duration_seconds` follows the same read-only draft convention: `null`
means unchanged and `0` stages clearing the setting. Write `"ring_duration_seconds": null`
to restore platform defaults on deployment. Public writes must be null or 5–120.

To turn a stepped agent back into a single-prompt agent, send `"steps": null`.
While that change is pending the response shows `"draft_steps": {}`, which means
"no steps on deploy". Send the text from
[`steps/flatten`](#render-steps-as-one-prompt) as `prompt` in the same request
to keep the steps' instructions.

To restore the organization's business hours, send `"business_hours": null`.
When this clears an existing override, the response shows
`"draft_business_hours": {"__inherit__": true}` until deployment. That marker
means "inherit on deploy"; it is not a weekday schedule. Send
`"timezone": ""` too to go back to the automatic time zone.

Production call paths (inbound, outbound, widget) always read the
deployed fields. Mic sessions and simulations read the draft with
fallback to the deployed value.

### Transfer numbers

`transfer_destinations` lists the numbers the agent may transfer calls to.
The prompt refers to each entry by `name`; the model sees the names and never
the numbers. See [Transfer calls](/guides/call-transfers) for prompting and
runtime behavior.

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Required. 1–60 characters after NFKC normalization and trimming, unique on the agent ignoring case. No line breaks, control characters, or invisible characters |
| `number` | string | Required. A phone number, or exactly one `{{variable}}` with no default. Numbers without a country code are read as US numbers and are stored in E.164. A variable is filled from the call's [variables](/guides/call-variables) when the call starts |
| `platform_ok` | boolean \| null | Read-only; ignored on write. `true` when calls on ThunderPhone numbers can transfer here (a US number that is not premium-rate or another special-service number). `false` means the number works only on transfers your own carrier carries (see [Transfer calls](/guides/call-transfers#which-numbers-a-call-can-reach)). `null` for a variable entry, which is checked on each call |

Writes replace the whole list. Sending `null` is rejected; send `[]` to remove
every entry. The same validation runs on create, update, deploy, and every
other write path:

- At most 50 entries.
- Only `name` and `number` are allowed. Other keys return
  `Unknown field(s): …` on that entry.
- A number must be a valid phone number without an extension. Numbers in the
  fictional range 555-0100 through 555-0199 are refused.
- If the organization has no number on its own carrier, a number that a
  ThunderPhone number can't reach (outside the US, premium-rate, shared-cost,
  personal-number, and similar types) is refused with a message that says
  why.
- A variable must be one placeholder on its own, such as `{{rep_phone}}`.
  `{{rep_phone|+14155552671}}` and text around the placeholder are refused.

Entry errors come back as a list with one object per submitted entry, in
order, and `{}` for entries that passed:

```json
{
  "transfer_destinations": [
    {},
    {"number": ["(415) 555-0134 is a fictional number: 555-0100 through 555-0199 are reserved for examples. Enter a real number."]},
    {"number": ["+44 20 7123 4567 is outside the US, and calls on ThunderPhone numbers can only transfer to US numbers. To transfer there, connect your own carrier under Phone Numbers."]}
  ]
}
```

Deploying re-checks the deployed list against the organization, so a non-US
entry saved while the organization had a number on its own carrier blocks the
deploy once it has none.

### Configuration warnings

`warnings` flags settings worth a second look. Every entry has a `code` and a
human-readable `message`; other keys depend on the code. Create, update and
retrieve responses all carry it. The older `tool_collision_warnings` field
carries the same entries except `timezone_not_set`; it is deprecated.

| `code` | Extra keys | Meaning |
|---|---|---|
| `tool_has_no_inputs` | `tool_name`, `integration_id` | An attached [integration](/api-reference/integrations) declares no inputs in `spec.function.parameters.properties`, so the agent may call it with no arguments. Add the fields your endpoint expects. Ignore this only if the endpoint really takes no data. When the last [analysis](#analyze-a-prompt) of the prompt finds the tool, this is replaced by `tool_arguments_not_declared`, or dropped when the prompt lists no arguments for it. |
| `tool_arguments_not_declared` | `tool_name`, `integration_id`, `arguments` | The prompt tells the agent to send `arguments` to this tool, but its `spec.function.parameters.properties` doesn't declare them. The agent sends only declared inputs reliably. |
| `api_mcp_tool_name_collision` | `tool_name`, `mcp_server_id`, `mcp_server_name` | An integration and an MCP tool share a name; the integration wins and the MCP tool is left out. |
| `handoff_target_missing`, `handoff_graph_truncated`, `handoff_tool_name_collision` | see [`handoffs`](#handoffs) | Handoff problems. |
| `timezone_not_set` | `suggested_timezone`, `source` | The agent has no `timezone`. `suggested_timezone` is the zone its calls use now and `source` says why (`phone_number`, `organization` or `default`). Set `timezone` to the zone its callers are in. |

Reading the agent never runs a prompt analysis. `tool_arguments_not_declared`
comes from the last [analysis](#analyze-a-prompt) of the current prompt, by
the dashboard builder or by `POST /v1/agents/{id}/prompt-analysis`. To get it
after changing a prompt over the API, call that endpoint with the agent's
`draft_prompt` (or `prompt` when there is no draft) exactly as the agent
response returns it, then read the agent again. A prompt that differs by so
much as a trailing newline is analyzed separately.

```json
"warnings": [
  {
    "code": "tool_has_no_inputs",
    "tool_name": "create_ticket",
    "integration_id": "6f1c2a9e-1b7d-4c55-9a0e-3f2d8b7c4e10",
    "message": "The tool 'create_ticket' declares no inputs, so the agent may call it without the data your endpoint needs. Add the fields it expects to spec.function.parameters.properties. Ignore this only if the endpoint really needs no data."
  }
]
```

### Product tiers at a glance

| Product | Optimized for | Acknowledgement |
|---------|---------------|-----------------|
| `spark` | Cost — fast and efficient for simple tasks | — |
| `bolt` | Speed — the lowest-latency tier, balanced for most use cases | — |
| `storm-base` | Intelligence — complex prompts and reasoning | — |
| `storm-base-with-ack` | Intelligence | Spoken filler (or tick) while thinking |
| `storm-extra` | Deepest reasoning | — |
| `storm-extra-with-ack` | Deepest reasoning | Spoken filler (or tick) while thinking |

## Steps and handoffs

Split an agent into **steps** when its calls move through phases, such as
greeting, identity checks and booking. Each step has its own instructions,
tools, an optional word-for-word line and values to collect. The agent moves
on by calling the built-in `go_to_step` tool, or when a code-checked condition
on the collected values holds. When `steps` is set, `prompt` holds the shared
instructions that apply in every step.

A **handoff** passes the call to another agent in the same organization. That
agent brings its own prompt, steps, tools and voice, and the transcript
carries over. Every other call setting (product, languages, speaking order,
voicemail, consent) comes from the agent the call started on.

Agents without steps or handoffs (`"steps": null`, `"handoffs": []`) work
exactly as before.

### `steps`

```json
{
  "start_step": "greet",
  "steps": [
    {
      "id": "greet",
      "name": "Greeting",
      "instructions": "Greet the caller and ask how you can help.",
      "say": "Thanks for calling Acme Dental, this is Ava.",
      "transitions": [
        {"to": "verify", "when": "The caller wants to book, move or cancel an appointment"}
      ]
    },
    {
      "id": "verify",
      "name": "Verify identity",
      "instructions": "Ask for the caller's full name and date of birth.",
      "tools": ["lookup_patient"],
      "collect": [
        {"name": "full_name", "type": "string", "description": "Caller's full name"},
        {"name": "dob", "type": "date", "description": "Date of birth"}
      ],
      "transitions": [
        {"to": "minor", "condition": {"value": "age", "op": "lt", "target": 18}},
        {"to": "book", "when": "Identity is confirmed"}
      ]
    },
    {"id": "minor", "name": "Minor", "instructions": "Ask for a parent or guardian."},
    {"id": "book", "name": "Book", "instructions": "Offer open slots and book one."},
    {
      "id": "billing",
      "name": "Billing questions",
      "instructions": "Answer questions about bills and payments.",
      "global_when": "The caller asks about a bill or payment",
      "transitions": [{"to": "__previous__", "always": true}]
    }
  ]
}
```

`start_step` names the step the call begins in. Each step takes:

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Required. Matches `[a-z][a-z0-9_]{0,39}`, unique within the agent |
| `name` | string | Required display name, 1–80 characters |
| `instructions` | string | Required, up to 20,000 characters. Supports `{{variables}}` like `prompt` |
| `say` | string \| null | Line spoken word for word when the step is entered, up to 1,000 characters. Supports `{{variables}}` |
| `tools` | array \| null | `null` (default) offers every agent tool. A list offers only the named agent tools in this step. Built-in tools such as ending or transferring the call follow the agent's settings in every step |
| `collect` | array | Up to 20 values to gather: `name` (same pattern as `id`, unique in the step), `type` (`string`, `number`, `boolean` or `date` as `YYYY-MM-DD`), optional `description` (up to 500 characters) and `required` (default `true`) |
| `on_enter_tool` | string \| null | An agent tool called automatically when the step is entered. Its result is stored as the value `<tool>_result`: `status` is the tool's HTTP status code and `response` is the response body |
| `transitions` | array | Ordered moves out of the step, up to 40. Each has `to` and exactly one trigger: `when` (the model decides, up to 1,000 characters), `condition` (code decides) or `"always": true`. `to` is a step `id`, or `"__previous__"` to return to the step that was active before a global step. A step with no transitions is the last one |
| `global_when` | string \| null | Makes the step reachable from any step when this holds (the model decides) |
| `voice` | string \| null | Voice for this step. Ignored when the call uses a custom `voice_config` |

A `condition` is `{"value", "op", "target"}`. `value` names a collected value
or a dotted path into a tool result, such as `lookup_patient_result.response.found`
(a field of the response body) or `lookup_patient_result.status` (its HTTP status code).
`op` is one of `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `in`, `not_in`, `exists`
or `missing`:

- `eq` and `ne` take a string, number or boolean; `in` and `not_in` take a
  list of 1–50 of them. Strings compare case-insensitively after trimming.
- `lt`, `lte`, `gt` and `gte` take a number or a `YYYY-MM-DD` date.
- `exists` and `missing` take no `target`.

A value that was never collected makes every op false except `missing`.
Conditions are checked in order when the agent moves on, and the first match
wins.

Limits: 100 steps and 200 KB of JSON per agent. Step `tools` and
`on_enter_tool` must name tools the agent has when you save. On an agent with
steps or handoffs, no tool may be named `go_to_step` or `hand_off`.

### `handoffs`

```json
[{"agent_id": 13, "when": "The caller asks about billing or a refund"}]
```

Up to 8 entries. `agent_id` must be another agent in the same organization;
`when` (1–1,000 characters) tells the agent when to hand off. A call includes
at most 8 agents, counted breadth-first from the agent it started on. The
agent's `warnings` lists `handoff_graph_truncated` when
handoffs further out are left out, `handoff_target_missing` when a handoff
names an agent that no longer exists (calls skip it), and
`handoff_tool_name_collision` when a handed-to agent has a different tool with
the same name as an earlier agent's (that tool is left out of the call).

### Validation errors

Invalid steps or handoffs return `400` with one key per field path:

```json
{
  "steps.steps[1].transitions[0].to": ["Unknown step id \"booking\"."],
  "handoffs[0].agent_id": ["No agent with this id in the organization."]
}
```

### Example

Stage steps and a handoff on the draft, then deploy:

<CodeGroup>
```bash cURL
curl -X PATCH https://api.thunderphone.com/v1/agents/12 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "You are Ava, the front desk for Acme Dental. Be brief and friendly.",
    "steps": {
      "start_step": "greet",
      "steps": [
        {
          "id": "greet",
          "name": "Greeting",
          "instructions": "Ask how you can help.",
          "transitions": [{"to": "book", "when": "The caller wants an appointment"}]
        },
        {"id": "book", "name": "Book", "instructions": "Offer open slots and book one."}
      ]
    },
    "handoffs": [{"agent_id": 13, "when": "The caller asks about billing"}]
  }'

curl -X POST https://api.thunderphone.com/v1/agents/12/deploy \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

---

## List agents

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/agents \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

```python Python
agents = requests.get(
    "https://api.thunderphone.com/v1/agents",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
).json()
```

```javascript Node.js
const agents = await fetch("https://api.thunderphone.com/v1/agents", {
  headers: { Authorization: `Bearer ${process.env.THUNDERPHONE_API_KEY}` },
}).then((r) => r.json());
```
</CodeGroup>

Returns an array of [Agent objects](#agent-object), sorted by `created_at` descending.

---

## Create an agent

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Customer Support Agent",
    "prompt": "You are a helpful support agent for Acme Ops…",
    "voice": "john",
    "product": "spark",
    "widget_enabled": true
  }'
```

```python Python
agent = requests.post(
    "https://api.thunderphone.com/v1/agents",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
    json={
        "name": "Customer Support Agent",
        "prompt": "You are a helpful support agent for Acme Ops…",
        "voice": "john",
        "product": "spark",
        "widget_enabled": True,
    },
).json()
```
</CodeGroup>

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | 1–255 chars |
| `prompt` | string | yes | System prompt |
| `voice` | string | yes | Voice name from [`/v1/voices`](#voices) (e.g. `john`). Entitled organizations can also pass [`custom:<public_id>`](/api-reference/custom-voices#use-a-custom-voice) for a ready custom voice, and an agent that does not process PHI can pass an [ElevenLabs voice](#elevenlabs-voices) (`elevenlabs:<voice_id>`); anything else is rejected with `"Unsupported voice option."` |
| `primary_language` | string | no | Defaults to `en` |
| `additional_languages` | array of string | no | ISO 639 (`es`) or BCP-47 (`es-ES`) codes |
| `native_voice_switching` | boolean | no | Use a native catalog voice, matched by gender, after switching to an additional language. Default `false` |
| `language_switching` | string | no | `ask_first` (default) or `follow_caller`. See [Deployed fields](#deployed-fields) |
| `product` | string | no | Defaults to `spark` |
| `thinking_level` | string | no | Ignored for Storm tiers (derived from `product`) |
| `background_track` | string | no | Track name or `"NONE"` |
| `extraction_fields` | array | no | Complete replacement list of structured fields to extract after each retained call. See [Deployed fields](#deployed-fields) for the item schema |
| `extraction_blocks_completion` | string | no | `except_transfers` (default), `always`, or `never`; controls customer completion delivery only |
| `outcome_labels` | array | no | Complete replacement list of call-report outcome labels; `[]` restores Success / Failure. See [Deployed fields](#deployed-fields) |
| `report_instructions` | string | no | Instructions for the call report's prose fields |
| `timezone` | string | no | The agent's time zone; see [Deployed fields](#deployed-fields). Optional, but an agent without one lists `timezone_not_set` in `warnings` |
| `business_hours` | object \| null | no | This agent's reporting hours; see [Deployed fields](#deployed-fields) |
| `acknowledgement_prompt_mode` | string | no | `auto` or `manual`. Only meaningful for Storm-with-ack products |
| `acknowledgement_prompt` | string | no | Required if `acknowledgement_prompt_mode="manual"` |
| `acknowledgement_feedback_mode` | string | no | `acknowledgement` (default) or `tick` |
| `inbound_speak_order` | string | no | Defaults to `agent_first` |
| `outbound_speak_order` | string | no | Defaults to `caller_first` |
| `voicemail_action` | string | no | `prompt` (default), `hangup`, or `message` |
| `voicemail_message` | string | no | ≤ 2000 chars |
| `silence_interval_seconds` | integer | no | 10–120, default 15 |
| `silence_max_checkins` | integer | no | 1–10, default 3 |
| `silence_checkins_enabled` | boolean | no | Default `true` |
| `silence_hangup_seconds` | integer | no | 60–3600, default 600; used when check-ins are disabled |
| `max_call_duration_seconds` | integer or null | no | 60–21600. Omit it to use the organization's new-agent default (3600, one hour); send `null` for no per-agent limit. Every call still ends after 6 hours |
| `ring_duration_seconds` | integer or null | no | Ringing time for outbound and transferred calls, in seconds. 5–120; null uses platform defaults (60s outbound, 45s warm transfer, provider default for cold transfer). Does not affect inbound ringing. |
| `connect_tone_enabled` | boolean | no | Default `false` |
| `typing_sounds_enabled` | boolean | no | Play a soft keyboard sound while a custom tool runs. Default `false` |
| `typing_sound` | string | no | `keys_and_mouse`, `mechanical`, `desktop`, `laptop`, `office`, or `quiet_room`. Default `keys_and_mouse` |
| `filler_phrases_enabled` | boolean | no | Long-wait updates on slow tools and replies. `false` stays quiet after the tool-start line. Default `true` |
| `tool_start_line` | string | no | `always`, `only_if_slow`, or `never`. Default `always` |
| `play_sound_asset` | string | no | `""` (none), `chime`, `ding`, or `beep` |
| `speak_uninterruptible_enabled` | boolean | no | Legacy compatibility field with no current runtime effect. Default `false` |
| `send_email_enabled` | boolean | no | Offer the built-in email tool when a recipient is configured. Default `false` |
| `send_email_recipient` | string | no | Fixed email recipient chosen by the agent owner; the model cannot change it |
| `calculator_enabled` | boolean | no | Offer the built-in `calculate` tool for exact arithmetic. Default `false` |
| `send_sms_enabled` | boolean | no | Offer `send_sms` when an eligible customer-connected sender is available. Default `false` |
| `sms_from_number` | string | no | E.164 fallback sender owned by the organization and confirmed SMS-capable by Twilio or Telnyx |
| `sms_allowed_recipients` | array of object | no | Up to 10 `{number, label}` contacts. Numbers are unique E.164 values. Labels are NFKC-normalized, unique case-insensitively, 1–60 visible characters, cannot look like phone numbers, and are caller-visible public metadata. Only labels are shown to the model. A configured contact enables real sends in widget, realtime, mic, and simulation sessions without broad recipient permission |
| `sms_allow_other_recipients` | boolean | no | Permit any valid E.164 recipient beyond the current telephone party and configured contacts. Default `false`. When enabled, the agent can text any number a caller dictates |
| `transfer_destinations` | array of object | no | Up to 50 `{name, number}` [transfer numbers](#transfer-numbers). Default `[]`: the agent can't transfer calls |
| `sms_followup_enabled` | boolean | no | Send one eligible post-call SMS. Default `false` |
| `sms_followup_template` | string | no | Plain-text post-call template, up to 1600 characters |
| `consent_announcement_enabled` | boolean | no | Defaults to `true`. New agents must be created with it enabled; disabling later requires the [attestation workflow](#consent-attestations) where applicable |
| `consent_announcement_text` | string | no | Non-blank call-start announcement text |
| `audio_context_mode` | string | no | `full` (default) or `reduced` |
| `watchdog_enabled` | boolean | no | Default `false` |
| `additional_audio_context` | boolean \| null | no | Override whether the last few caller-audio turns are included. `null` keeps the direction-based default |
| `widget_enabled` | boolean | no | Defaults to `true` |
| `split_testing_enabled` | boolean | no | Default `false` |
| `integration_ids` | array of UUID | no | Link reusable [integrations](/api-reference/integrations) by id |
| `knowledge_base_ids` | array of UUID | no | Attach [knowledge bases](/api-reference/knowledge-bases) |
| `knowledge_document_ids` | array of UUID | no | Attach individual knowledge documents |
| `mcp_server_ids` | array of UUID | no | Attach [MCP servers](/api-reference/mcp-servers) |
| `reusable_instruction_ids` | array of UUID | no | Attach [reusable instructions](/api-reference/reusable-instructions) |
| `call_summary_enabled` | boolean | no | Generate a post-call summary. Default `false` |

Returns `201 Created` with the new [Agent object](#agent-object). A
create deploys immediately (there is no draft yet) and records revision
1 in the [version history](#version-history).

<Note>
  `max_hold_seconds` (outbound hold timeout, default 900 s) and inline
  `tools` exist on the underlying model but are **not writable through
  this API** — tool configuration is managed via
  [Integrations](/api-reference/integrations),
  [App connections](/api-reference/provider-connections), and
  [MCP servers](/api-reference/mcp-servers).
</Note>

---

## Retrieve an agent

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/agents/12 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

```python Python
agent = requests.get(
    f"https://api.thunderphone.com/v1/agents/{agent_id}",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
).json()
```
</CodeGroup>

Returns `200 OK` with an [Agent object](#agent-object), or `404` if not found.

---

## Update an agent (writes the draft)

`PATCH` and `PUT` do **not** modify the deployed configuration.
Every field you send is staged into the corresponding `draft_*` column;
production calls keep using the deployed values until you
[deploy](#deploy-the-draft). Sending a value identical to the deployed
value *clears* that field's draft (so manually reverting an edit
removes the pending change).

<CodeGroup>
```bash cURL
curl -X PATCH https://api.thunderphone.com/v1/agents/12 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "You are a refined support agent…"}'
```

```python Python
agent = requests.patch(
    f"https://api.thunderphone.com/v1/agents/{agent_id}",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
    json={"prompt": "You are a refined support agent…"},
).json()
```
</CodeGroup>

Accepts the same fields as [create](#create-an-agent), including the
`integration_ids` / `knowledge_base_ids` / `knowledge_document_ids` /
`mcp_server_ids` / `reusable_instruction_ids` lists (staged as draft
attachment lists). Returns
`200 OK` with the [Agent object](#agent-object) — inspect the
`draft_*` fields and `has_draft` to see what is pending.

### Optimistic concurrency

To safely make edits from a UI, include `if_updated_at` (or
`updated_at`) with the last `updated_at` you observed:

```json
{
  "prompt": "Newer prompt",
  "if_updated_at": "2026-04-20T18:24:10.113Z"
}
```

If the server-side `updated_at` differs, the API returns `409 Conflict`
with `{"detail": "Agent was modified by another user. Please refresh and try again."}`
so you can refresh and retry.

---

## Deploy the draft

Promotes every pending `draft_*` value to the deployed configuration,
clears the draft, and records a new revision in the
[version history](#version-history).

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/12/deploy \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

```python Python
agent = requests.post(
    f"https://api.thunderphone.com/v1/agents/{agent_id}/deploy",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
).json()
```
</CodeGroup>

Returns `200 OK` with the updated [Agent object](#agent-object)
(`has_draft` is now `false`). Deploying with no pending draft is a
no-op that still returns `200` with the current agent.

## Discard the draft

Clears every pending draft field, reverting the editor state to the
deployed configuration.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/12/discard-draft \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

Returns `200 OK` with the [Agent object](#agent-object).

## Intro clip

`GET /v1/agents/{agent_id}/intro-clip` ·
`POST /v1/agents/{agent_id}/intro-clip` ·
`DELETE /v1/agents/{agent_id}/intro-clip`

Manage the agent's custom intro clip (`intro_clip: "custom"`). Like every
agent edit, an upload or removal changes the **draft**; it reaches live calls
when you [deploy](#deploy-the-draft).

**Upload** with a multipart `audio` field. The file must be a PCM WAV,
**48,000 Hz, mono, 16-bit**, 0.5–15 seconds long and at most 2 MB; anything
else is refused with `400` and an `audio` error. (The dashboard converts any
file or browser recording to this format for you.) The upload sets
`draft_intro_clip` to `custom` along with `draft_intro_clip_artifact_path` and
`draft_intro_clip_sha256`.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/12/intro-clip \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -F "audio=@intro.wav;type=audio/wav"
```
</CodeGroup>

Returns `201 Created`:

```json
{
  "artifact_path": "agents/intro-clips/7/12/3f5a…c9.wav",
  "sha256": "3f5a…c9",
  "duration_seconds": 4.2,
  "updated_at": "2026-10-13T18:02:11.482913Z"
}
```

`updated_at` is the agent's new value, for
[optimistic concurrency](#optimistic-concurrency).

**Download** returns the clip the draft would play (the draft's clip if one is
staged, otherwise the deployed one) as `audio/wav`, or `404` when there is
none.

**Remove** stages removal of the custom clip; if `intro_clip` was `custom`,
the draft also switches it to `none`. Returns `200 OK` with the draft's
resulting `intro_clip` and `updated_at`:

```json
{ "intro_clip": "none", "updated_at": "2026-10-13T18:05:40.120004Z" }
```

---

## Consent attestations

`GET /v1/agents/{agent_id}/consent-attestations` ·
`POST /v1/agents/{agent_id}/consent-attestations`

Every agent plays a call-start consent announcement (recording and AI
disclosure) by default. Disabling it on a non-grandfathered agent requires
an **active attestation**: an admin's recorded affirmation that your
organization will provide any legally required notice and obtain any
legally required consent for its calls while the announcement stays off
(see the [call recording consent laws](/guides/recording-consent-laws)
guide for what that involves). Without one, staging or deploying
`consent_announcement_enabled: false` returns `400`.

Attestations are single-use per disable cycle: re-enabling the
announcement retires the active attestation (recorded with
`status_reason: "superseded-by-reenable"`), so the next disable needs a
fresh affirmation. ThunderPhone may also suspend or revoke attestations,
which restores the announcement at runtime. New attestations are recorded
only under the **current** `attestation_text_version`, but an active
attestation recorded under an earlier accepted version stays valid — a
statement-copy revision never re-prompts an organization that already
attested.

`GET` lists the agent's attestations (newest first) and returns the
current `attestation_text_version` **and the server-owned canonical
statement package** — `statement_text`, its `statement_sha256`, and the
`statement_locale` it resolved for your `?locale=` (English until a locale
is registered). Display `statement_text` verbatim to the admin, then
`POST` with `affirmed: true`, the exact version, your `locale`, and
**assent to the package**: echo `statement_sha256`, or send the displayed
text as `rendered_text` (it must hash to the canonical value). The server
stores its canonical hash — blank or mismatched statement evidence is
rejected.

**Step 1 — fetch the canonical statement:**

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/agents/12/consent-attestations?locale=en \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response (excerpt)
{
  "attestation_text_version": "consent-disable-v4-2026-08-03",
  "statement_locale": "en",
  "statement_text": "Turn off the call-recording announcement?\n…",
  "statement_sha256": "7a284954b61b6610d1f9fb43ec05e82e6aec4362956fa5409deb3375cfc99035",
  "results": []
}
```

**Step 2 — display the statement, collect the affirmation, and record it:**

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/12/consent-attestations \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"affirmed": true, "attestation_text_version": "consent-disable-v4-2026-08-03", "statement_sha256": "7a284954b61b6610d1f9fb43ec05e82e6aec4362956fa5409deb3375cfc99035", "locale": "en"}'
```
</CodeGroup>

```json Response
{
  "id": 91,
  "organization_id": 7,
  "agent_id": 12,
  "user_id": 3,
  "attestation_text_version": "consent-disable-v4-2026-08-03",
  "status": "active",
  "attested_at": "2026-08-03T23:02:11.480Z",
  "status_updated_at": null,
  "affirmed": true,
  "attestation_text_sha256": "7a284954b61b6610d1f9fb43ec05e82e6aec4362956fa5409deb3375cfc99035",
  "locale": "en",
  "user_role": "admin",
  "status_reason": ""
}
```

| Status | Condition |
|--------|-----------|
| `201` | Attestation recorded (POST) |
| `400` | `affirmed` was not `true`, `attestation_text_version` does not match the current version, `locale` is blank, or the statement evidence (`statement_sha256`/`rendered_text`) is blank or does not match the canonical package |
| `403` | Caller is not an org admin |

## PHI designation attestation

`GET /v1/phi-designation-attestation?agent_id={agent_id}` ·
`POST /v1/phi-designation-attestation`

Only organizations whose BAA includes the PHI designation clause
(`baa_status: "executed_with_designation"`) use this endpoint. In such an
organization every agent processes PHI by default. Before an agent's
`phi_processing_override` can be set to `no_phi`, an organization admin
must record an attestation for that agent; without one, saving or deploying
the override returns `400`.

`GET` returns the current `text_version`, the canonical English statement
(`statement_en`) and its `statement_sha256`, the organization's
`baa_status` and `phi_processing`, and `designation_available`. With
`agent_id`, it also reports whether that agent has an active attestation
(`attested`, `attestation`).

`POST` takes `agent_id`, `accepted: true`, the `text_version` and the
`statement_sha256` from `GET`, and an optional display `locale` (default
`en`; the English statement governs). It returns `201` with the
attestation, or `200` with the existing one if the agent already has an
active attestation.

<Note>
  The attestation must be recorded by an organization admin as a user, not
  with an organization API key: `POST` with an `sk_live_` key returns `403`.
  `GET` works with a key.
</Note>

<CodeGroup>
```bash cURL
curl "https://api.thunderphone.com/v1/phi-designation-attestation?agent_id=12" \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response (excerpt)
{
  "text_version": "phi-designation-no-phi-v1-2026-09-24",
  "statement_en": "I am authorized to designate this agent as not processing protected health information (PHI). …",
  "statement_sha256": "7776b7852f41e777eac228bfd5abdc4df5c958752b39ea032c86f14b02a892da",
  "baa_status": "executed_with_designation",
  "phi_processing": "phi",
  "designation_available": true,
  "attested": false,
  "attestation": null
}
```

| Status | Condition |
|--------|-----------|
| `201` | Attestation recorded (POST) |
| `200` | The agent already has an active attestation (POST) |
| `400` | Unknown `agent_id`, `accepted` was not `true`, `locale` is blank, `text_version` is not accepted, or `statement_sha256` does not match the canonical statement |
| `403` | API key, or caller is not an org admin (POST) |
| `409` | The organization's BAA has no designation clause, so there is nothing to attest |

---

## Delete an agent

<CodeGroup>
```bash cURL
curl -X DELETE https://api.thunderphone.com/v1/agents/12 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

```python Python
requests.delete(
    f"https://api.thunderphone.com/v1/agents/{agent_id}",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
)
```
</CodeGroup>

Returns `204 No Content`. Phone numbers that reference the deleted agent
have their inbound/outbound assignment cleared.

An agent that other agents [hand off to](#steps-and-handoffs), in their
deployed or draft `handoffs`, cannot be deleted. The request returns
`409 Conflict` with `"code": "agent_is_handoff_target"` and the referencing
agents in `agents` (`[{"id", "name"}]`). Remove those handoffs first.

---

## Duplicate an agent

Creates a new agent in the same org with everything configured for the
source agent: all settings, both deployed and pending drafts, the same
attached integrations, knowledge bases/documents, MCP servers and reusable
instructions, and new copies of its agent-scoped webhook endpoints (same URL,
events and signing secret), calendar/CRM connections, split-testing variants,
test scenarios and suites (with schedules), and client portal listings. The
name defaults to `<name> (Copy)` and can be overridden. If the source has a
pending draft, the copy has one too (`has_draft: true`), and deploying it
ships those edits.

A few settings are reset rather than copied:

- A consent announcement turned off by attestation stays off, and the copy
  gets its own attestation record carrying the original's. A copy of an agent
  exempted without an attestation starts with the announcement on.
- A live-processing disclosure set to off goes back to the org default.
- A PHI override the org doesn't allow goes back to the org default.

Phone numbers, SIP addresses, widget publishable keys, campaigns and call
history stay with the source agent. Move a number to the copy separately.
Steps and handoffs are copied as they are, so the copy hands off to the same
agents.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/12/duplicate \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Customer Support Agent (clone)"}'
```

```python Python
clone = requests.post(
    f"https://api.thunderphone.com/v1/agents/{agent_id}/duplicate",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
    json={"name": "Customer Support Agent (clone)"},
).json()
```
</CodeGroup>

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | no | Defaults to `"<original name> (Copy)"` |

Returns `201 Created` with the new [Agent object](#agent-object).

---

## Transfer an agent to another org

Copy or move an agent to a different organization. `admin+` role is required
on **both** orgs.

An `sk_live_` API key is limited to its own organization: with a key,
`target_org_id` must be the key's organization, and any other target
returns `403`. Copy or move between organizations from a signed-in session
(`Authorization: Token <session-token>` plus `X-ThunderPhone-Org` for the
source org), as below.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/12/transfer \
  -H "Authorization: Token YOUR_SESSION_TOKEN" \
  -H "X-ThunderPhone-Org: 42" \
  -H "Content-Type: application/json" \
  -d '{
    "target_org_id": 77,
    "mode": "copy"
  }'
```

```python Python
result = requests.post(
    f"https://api.thunderphone.com/v1/agents/{agent_id}/transfer",
    headers={"Authorization": "Token YOUR_SESSION_TOKEN", "X-ThunderPhone-Org": "42"},
    json={"target_org_id": 77, "mode": "copy"},
).json()
```
</CodeGroup>

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `target_org_id` | integer | yes | For `mode="move"` it must differ from the source org id |
| `mode` | string | no | `copy` (default — keep source intact) or `move` (re-home the agent) |
| `name` | string | no | Override the new agent's name |

`copy` makes the same copy as [Duplicate](#duplicate-an-agent), with the
target org's rules applied: integrations, knowledge, MCP servers and reusable
instructions attach only if the same record belongs to the target org;
webhook endpoints, calendar/CRM connections, client portal listings, legacy
inline `tools`, and test scenarios created from real calls are not copied and
are named in `not_copied`; copied test-suite schedules start disabled; SMS
sending is off until the target org picks one of its own numbers; transfer
numbers the target org could not save (numbers a ThunderPhone number can't
reach, when the target org has no number on its own carrier) are left behind
and reported as `transfer_destinations` in `not_copied`; and the consent
announcement starts on, because another org's attestation does not
cover the target org.

Returns `201 Created` for `copy` (with `not_copied`, a list such as
`["webhook_endpoints"]`) / `200 OK` for `move`:

```json
{
  "mode": "move",
  "source_org_id": 42,
  "target_org_id": 77,
  "agent": { /* Agent object in the target org */ },
  "detached": {
    "publishable_keys": 1,
    "webhook_endpoints": 0,
    "provider_connections": 1,
    "client_portals": 0,
    "transfer_destinations": 0
  }
}
```

<Note>
  A move is rejected while the agent has campaigns, validation examples,
  or pending/running validation or simulation runs. Finish or remove that
  work before retrying. Source-owned phone assignments, publishable keys,
  agent-scoped webhooks, provider connections, client portals, integrations,
  knowledge, and MCP servers are detached or disabled and counted in the
  response. Call logs, completed runs, pipeline reports, and import history
  remain in the source organization. Agent variants and prompt analyses move
  with the agent.
</Note>

<Note>
  Copies keep the agent's `steps`. Handoff targets are agents of the source
  organization, so cross-organization copies and moves drop `handoffs`
  (deployed and draft), and a move reports how many it dropped as
  `detached.handoffs`. A move is also rejected while other agents hand off to
  the agent.
</Note>

<Note>
  Cross-organization copies and moves clear deployed and draft SMS senders,
  enablement, broad-recipient permission, and configured contacts. Moved
  version snapshots are scrubbed too, so restoring an old version cannot
  recover the source organization's SMS authority. Configure SMS again in the
  destination organization.
</Note>

---

## Version history

Deploys (and creates/duplicates/transfers) record an
`AgentConfigVersion` snapshot of the **deployed** configuration.
Draft autosaves are *not* versioned — the history reflects what
actually shipped to production.

<CodeGroup>
```bash cURL
curl 'https://api.thunderphone.com/v1/agents/12/versions?limit=20' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

```python Python
versions = requests.get(
    f"https://api.thunderphone.com/v1/agents/{agent_id}/versions",
    params={"limit": 20},
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
).json()
```
</CodeGroup>

| Query param | Default | Description |
|-------------|---------|-------------|
| `limit` | 20 | Max results. Values outside 1–100 are clamped |

Response:

```json
{
  "count": 3,
  "results": [
    {
      "id": 501,
      "revision": 3,
      "created_by_id": 7,
      "created_at": "2026-04-20T18:24:10.113Z",
      "snapshot": { /* Deployed agent config at this revision */ },
      "changed_fields": ["prompt", "voice"]
    }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Version row id |
| `revision` | integer | 1-based, increments per deploy |
| `created_by_id` | integer \| null | User id that deployed the revision |
| `snapshot` | object | Full deployed config at this revision |
| `changed_fields` | array of string | Fields that differ from the previous revision |

---

## Prompt helpers

These endpoints are convenience wrappers that call the same
underlying models we use in the dashboard. All of them take and return
JSON; none change your agents or any other resource you manage.

### Detect prompt language

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/prompt-language-check \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Hola, gracias por llamar..."}'
```
</CodeGroup>

```json Response
{
  "detected_language_code": "es",
  "detected_language_name": "Spanish",
  "mismatch": true,
  "provider": "<provider id>"
}
```

### Translate prompt

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/translate-prompt \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Hola, gracias por llamar…",
    "target_locale": "en-US"
  }'
```
</CodeGroup>

```json Response
{
  "translated_prompt": "Hello, thank you for calling…",
  "source_language_code": "es",
  "source_language_name": "Spanish",
  "provider": "<provider id>",
  "used_fallback": false
}
```

### Generate acknowledgement prompt

Produces a short "filler while thinking" phrase appropriate for
Storm-with-ack products.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/generate-acknowledgement-prompt \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "You are a friendly receptionist for a dental office.",
    "target_locale": "en-US"
  }'
```
</CodeGroup>

```json Response
{
  "acknowledgement_prompt": "Sure, let me take a quick look…",
  "provider": "<provider id>",
  "used_fallback": false
}
```

### Render steps as one prompt

Returns the shared instructions and steps as one prompt, in the form a
stepped agent falls back to when steps cannot run. Use it to turn a stepped
agent back into a single-prompt agent: send the result as `prompt` together
with `"steps": null`. `steps` is checked for shape only, and nothing is saved.
With `"steps": null`, `prompt` comes back unchanged.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/steps/flatten \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "You are Ava, the front desk for Acme Dental.",
    "steps": {
      "start_step": "greet",
      "steps": [
        {
          "id": "greet",
          "name": "Greeting",
          "instructions": "Ask how you can help.",
          "transitions": [{"to": "book", "when": "The caller wants an appointment"}]
        },
        {"id": "book", "name": "Book", "instructions": "Offer open slots and book one."}
      ]
    }
  }'
```
</CodeGroup>

```json Response
{
  "prompt": "You are Ava, the front desk for Acme Dental.\n\n## Conversation flow\n1. Greeting (start here): Ask how you can help.\n   - If The caller wants an appointment → go to Book\n2. Book: Offer open slots and book one.\n"
}
```

### Analyze a prompt

Runs the same advisory checks the agent builder shows under the prompt
editor. A model extracts facts from the prompt text (languages, tools it
mentions, escalation and consent guidance), and the API compares them
with the agent's configuration. Nothing is saved to the agent, so you can
send an unsaved draft. Requires the `member` role or higher.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/agents/123/prompt-analysis \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Eres la recepcionista de una clínica dental. Usa book_appointment para agendar citas.",
    "primary_language": "en",
    "additional_languages": [],
    "voice": "john",
    "integration_ids": [],
    "mcp_server_ids": []
  }'
```
</CodeGroup>

| Field | Type | Description |
|-------|------|-------------|
| `prompt` | string | **Required.** The prompt text to analyze. May be empty. Max 100,000 characters. |
| `primary_language` | string | Language code to check against. |
| `additional_languages` | string[] | Additional language codes to check against. |
| `voice` | string | Voice to check language coverage against. |
| `integration_ids` | integer[] | Integrations to treat as connected. |
| `mcp_server_ids` | integer[] | MCP servers to treat as connected. |
| `integration_public_ids` | string[] | The same as `integration_ids`, by the integration `id` the agent and integration endpoints return. Ignored when `integration_ids` is sent. |
| `mcp_server_public_ids` | string[] | The same as `mcp_server_ids`, by the MCP server `id`. Ignored when `mcp_server_ids` is sent. |

Every field except `prompt` is optional. An omitted field uses the
agent's draft value, or the deployed value when the draft has none, so
the findings depend on the agent unless you send every field, as the
example does.

```json Response
{
  "facts": {
    "languages": {
      "declared": [],
      "written_in": [{"code": "es", "share": 1.0}]
    },
    "tools": {
      "referenced": [
        {"name": "book_appointment", "kind": "identifier", "evidence": "Usa book_appointment para agendar citas"}
      ]
    },
    "escalation": {"present": false, "kind": "none", "evidence": ""},
    "compliance": {"present": false, "evidence": ""}
  },
  "findings": [
    {
      "id": "languages-not-configured",
      "severity": "warning",
      "params": {"languages": [{"code": "es", "supported_by_voice": true}]}
    },
    {
      "id": "primary-language-mismatch",
      "severity": "warning",
      "params": {"detected": "es", "primary": "en"}
    },
    {
      "id": "tool-actions-without-tools",
      "severity": "warning",
      "params": {"tools": ["book_appointment"]}
    },
    {"id": "missing-escalation-guidance", "severity": "warning", "params": {}},
    {"id": "missing-consent-or-privacy-guidance", "severity": "info", "params": {}}
  ]
}
```

| Finding `id` | Severity | Meaning |
|--------------|----------|---------|
| `languages-not-configured` | `warning` | The prompt declares a language, or has at least 20% of its text in one, that the agent isn't configured for. `params.languages` lists each code and whether the voice supports it. |
| `primary-language-mismatch` | `warning` | At least 60% of the prompt is written in `params.detected`, not the primary language `params.primary`. |
| `tool-actions-without-tools` | `warning` or `info` | The prompt promises actions that need an external system, but no integration or MCP server is connected. `warning` when it names a tool identifier, `info` when it only describes an action. `params.tools` lists up to five. |
| `tool-arguments-not-declared` | `warning` | The prompt tells the agent to send arguments that a connected integration does not declare in `spec.function.parameters.properties`. `params.undeclared` lists up to five tools as `{name, arguments}`. |
| `missing-escalation-guidance` | `warning` | The prompt gives no path to a human, transfer or callback. |
| `missing-consent-or-privacy-guidance` | `info` | The prompt has no consent, recording-notice, privacy or disclosure guidance. |

`facts` is what the model extracted; `findings` are computed from the
facts and the configuration on every request. New finding ids and fact
keys may be added, so ignore ones you don't recognize.

The analysis is advisory and never fails the request because the model
did:

- If the model is unavailable or returns something unusable, you get
  `200` with `{"findings": [], "facts": null, "error": "unavailable"}`.
- Facts are cached per agent and prompt text. Repeating a prompt returns
  instantly, with findings recomputed against the current configuration.
- Uncached requests are throttled per agent. A new model request starts
  no sooner than two seconds after the previous one started, and while
  one is still running it holds a 20-second lease. A request inside
  either window returns the last good result with `"stale": true` and
  `retry_after_ms`. If there is no earlier result it returns `202` with
  only `retry_after_ms`. Either way, retry after that many milliseconds.

---

## Voices

List the supported voices and languages — or browse the [voice gallery](/api-reference/voices) with playable samples:

```bash
curl 'https://api.thunderphone.com/v1/voices?languages=en,es' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

```json Response
{
  "voices": [
    {
      "name": "john",
      "display_name": "John",
      "gender": "male",
      "category": "multilingual",
      "accent": "american",
      "languages": ["en", "es", "fr", "…"],
      "sample_url": "https://…/john.mp3?v=3"
    }
  ],
  "languages": [ /* language catalog entries */ ],
  "elevenlabs": {
    "provider": "ElevenLabs",
    "model": "eleven_v4_turbo",
    "model_label": "Eleven v4 Turbo",
    "surcharge_cents": 5,
    "hipaa_eligible": false,
    "available": true,
    "unavailable_reason": null,
    "languages": ["ar", "bn", "…"],
    "voices": [
      {
        "name": "elevenlabs:UgBBYS2sOqTuMpoF3BR0",
        "display_name": "Mark",
        "tagline": "Natural Conversations",
        "description": "A casual, young-adult speaking in a natural manner. Perfect for Conversational AI.",
        "gender": "male",
        "age": "young",
        "accent": "american",
        "native_language": "en",
        "use_case": "conversational",
        "sample_url": "https://storage.googleapis.com/eleven-public-prod/…/0Oc7jiXwWN9kRTXfQsmw.mp3"
      }
    ]
  }
}
```

| Query param | Description |
|-------------|-------------|
| `languages` | Comma-separated codes — only voices supporting **all** of them are returned |
| `category` | Filter by voice category |
| `gender` | Filter by voice gender |

The `name` is what you set on an agent's `voice` field.

### ElevenLabs voices

The `elevenlabs` block lists voices from ElevenLabs under ElevenLabs' own names,
on the Eleven v4 Turbo model. Every voice speaks every code in its `languages`,
which is the whole language catalog, and costs `surcharge_cents` per minute in
every language, in place of any language or premium-voice surcharge. The
`gender` filter applies to this block too; `languages` and `category` keep it
only when every requested language is covered or the category is `elevenlabs`.

ElevenLabs voices are **not HIPAA eligible yet** (`hipaa_eligible: false`):

- An agent, draft, step, split-test variant or realtime session whose PHI
  designation resolves to PHI cannot select one. The request fails with
  `"ElevenLabs voices are not HIPAA eligible yet. Ask us to enable HIPAA for them, or pick another voice."`,
  as does switching an agent that uses one to processing PHI.
- `available` is `false` with `unavailable_reason: "phi"` when your whole
  organization resolves to PHI. Individual agents may still be designated as
  not processing PHI; the check above is per agent.
- If a call to an agent with an ElevenLabs voice runs as a PHI call anyway, it
  uses a standard voice for the call language and carries no ElevenLabs charge.

---

## Related

<CardGroup cols={2}>
  <Card title="Voice agent settings" icon="sliders" href="/guides/agent-settings">
    Match API fields to the dashboard controls.
  </Card>
  <Card title="Built-in tools" icon="wrench" href="/guides/built-in-tools">
    Configure platform-run actions and their limits.
  </Card>
  <Card title="Transfer calls" icon="phone-arrow-right" href="/guides/call-transfers">
    Set cold and screened warm-transfer routing.
  </Card>
  <Card title="Phone Numbers" icon="phone" href="/api-reference/phone-numbers">
    Assign agents to inbound/outbound directions on phone numbers.
  </Card>
  <Card title="Reusable instructions" icon="list-check" href="/api-reference/reusable-instructions">
    Organization-wide prompt sections attached via `reusable_instruction_ids`.
  </Card>
  <Card title="Knowledge bases" icon="book" href="/api-reference/knowledge-bases">
    Ground the agent in your documents.
  </Card>
  <Card title="Split testing" icon="shuffle" href="/api-reference/split-testing">
    A/B test prompt and voice variants on live traffic.
  </Card>
  <Card title="Test scenarios" icon="flask" href="/api-reference/test-scenarios">
    Regression-test the agent before deploying.
  </Card>
</CardGroup>
