ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Agents

Agents

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. For prompt-callable actions, see Built-in tools and Transfer calls.

Endpoints

MethodPathDescription
GET/v1/agentsList agents
POST/v1/agentsCreate 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}/deployPromote the draft to the deployed config
POST/v1/agents/{agent_id}/discard-draftThrow away all pending draft changes
DELETE/v1/agents/{agent_id}Delete an agent
POST/v1/agents/{agent_id}/duplicateDuplicate an agent
POST/v1/agents/{agent_id}/transferCopy or move an agent to another org
GET/v1/agents/{agent_id}/versionsList deployed configuration revisions
GET / POST / DELETE/v1/agents/{agent_id}/intro-clipDownload, upload or remove the agent's custom intro clip
GET / POST/v1/agents/{agent_id}/consent-attestationsList or record consent-announcement attestations
GET / POST/v1/phi-designation-attestationRead or record an agent's no-PHI designation attestation
POST/v1/agents/prompt-language-checkDetect the language of a prompt
POST/v1/agents/translate-promptTranslate a prompt into another locale
POST/v1/agents/generate-acknowledgement-promptAuto-generate a Storm-mode acknowledgement prompt
POST/v1/agents/steps/flattenRender shared instructions and steps as one prompt
POST/v1/agents/{agent_id}/prompt-analysisCheck a prompt against the agent's configuration

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

Agent object

{
  "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

FieldTypeDescription
idintegerServer-assigned agent id
namestringHuman-readable name. 1–255 chars
promptstringThe system prompt that governs agent behavior
voicestringVoice name from GET /v1/voices — e.g. john, megan. Entitled organizations can also pass custom:<public_id> for a ready custom voice, and any agent that does not process PHI can use an ElevenLabs voice as elevenlabs:<voice_id>
primary_languagestringISO 639 code, e.g. en, es. Default en. Drives the default TTS locale
additional_languagesarray of stringExtra language codes the agent supports (es or BCP-47 es-ES)
native_voice_switchingbooleanFor 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_switchingstringHow 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
productstringProduct tier. One of: spark, bolt, storm-base, storm-base-with-ack, storm-extra, storm-extra-with-ack
thinking_levelstringbase or extra. Derived from product for Storm tiers
background_trackstringAmbient audio track name, or "NONE" for silence. Never null — a null write is coerced to "NONE"
acknowledgement_prompt_modestringauto (we generate the ack prompt) or manual (you supply it)
acknowledgement_promptstringUsed when acknowledgement_prompt_mode="manual". Storm-tier only
acknowledgement_feedback_modestringWhile a Storm agent thinks: acknowledgement (spoken filler) or tick (ticking sound)
inbound_speak_orderstringWho talks first on inbound calls. agent_first or caller_first
outbound_speak_orderstringWho talks first on outbound calls
voicemail_actionstringWhat to do when a voicemail system answers: prompt (let the agent decide), hangup, or message (leave a message)
voicemail_messagestringTTS template for voicemail_action="message", max 2000 chars. Supports {agent_name} and {org_name} variables
silence_interval_secondsintegerSeconds between "are you still there?" check-ins. 10–120, default 15
silence_max_checkinsintegerCheck-ins before the agent hangs up. 1–10, default 3
silence_checkins_enabledbooleanWhether the agent proactively checks in during caller silence. Default true
silence_hangup_secondsintegerCaller-idle seconds before a silent hangup when check-ins are disabled. 60–3600, default 600
max_call_duration_secondsinteger or nullMaximum 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_secondsinteger or nullRinging 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_enabledbooleanPlay a short connection tone before the agent begins. Default false
typing_sounds_enabledbooleanPlay a soft keyboard sound while a custom tool runs. Platform-managed tools do not trigger it. Default false
typing_soundstringWhich 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_enabledbooleanLong-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_linestringTool-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_assetstringPrompt-callable sound asset: "" (none), chime, ding, or beep
intro_clipstringPrerecorded 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; 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_pathstringRead-only. Storage path of the uploaded custom clip, "" when there is none
intro_clip_sha256stringRead-only. SHA-256 of the uploaded custom clip, "" when there is none
intro_ring_countintegerUS rings played after the intro clip, before the agent speaks. 0–2, default 1. Only used when intro_clip is not none
speak_uninterruptible_enabledbooleanLegacy compatibility field. The speak_uninterruptible tool is currently unavailable, so this setting has no runtime effect. Default false
send_email_enabledbooleanOffer the built-in email tool when send_email_recipient is also set. Default false
send_email_recipientstringFixed recipient for the built-in email tool. The model supplies only the subject and body and cannot choose or change this address
calculator_enabledbooleanOffer the built-in calculate tool, which adds up totals and prices exactly. Default false
send_sms_enabledbooleanOffer the send_sms tool when a customer-connected SMS-capable sender is available. Default false
sms_from_numberstringOptional E.164 fallback sender on the organization's connected Twilio or Telnyx account
sms_allowed_recipientsarray of objectUp 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_recipientsbooleanAllow 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_destinationsarray of objectThe agent's 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_stylestringWhat happens when a warm transfer 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_briefingstringWhat the agent tells the person who answers a warm transfer 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_enabledbooleanSend one SMS from the customer carrier after an eligible completed telephone call. Default false
sms_followup_templatestringPlain-text follow-up template. Supports call variables and extracted fields when present
consent_announcement_enabledbooleanPlay the configured AI/recording announcement at call start. Default true; disabling requires an active consent attestation where applicable
consent_announcement_textstringAnnouncement played verbatim at call start; must not be blank and is limited to 2000 characters in the dashboard
consent_disabled_reasonstringRead-only platform-managed status associated with an announcement exception. Clients cannot set it
audio_context_modestringAPI-only Storm compatibility control: full or reduced (lower latency, less prior audio). This is separate from additional_audio_context
watchdog_enabledbooleanEnables live supervision of agent turns (Storm). Default false
additional_audio_contextboolean | nullBacking 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_fieldsarrayComplete 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_labelsarrayHow calls with a gradeable conversation are labelled in the 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_instructionsstringRules 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.
timezonestringIANA 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, 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.
business_hoursobject | nullWeekly 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_enabledbooleanGenerate a plain-language summary after each call (dashboard: Generate a call summary under Data to collect). Delivered as summary on telephony.complete / web.complete and on the 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_idsarrayUUIDs of the organization's 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_completionstringWhen 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.
integrationsarrayLinked Integration objects
knowledge_basesarrayAttached knowledge bases (full objects)
knowledge_documentsarrayIndividually attached knowledge documents
mcp_serversarrayAttached MCP servers (full objects)
widget_enabledbooleanWhether the agent is reachable via the embeddable web widget
split_testing_enabledbooleanWhether calls are split across A/B variants
warningsarrayRead-only configuration warnings for the draft the builder and test calls use: tool, handoff and time zone problems. Never blocks a save. Default []
tool_collision_warningsarrayDeprecated: read warnings. The same list without timezone_not_set.
stepsobject | nullSplits the agent into steps. null (default) is a single-prompt agent. When set, prompt holds the shared instructions that apply in every step
handoffsarrayOther agents in the organization this agent can hand the call to. Default []
created_at, updated_attimestampISO 8601 UTC

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:

FieldTypeDescription
draft_<field>same as field | nullnull = no pending change for that field; non-null = the value staged for the next deploy
draft_updated_attimestamp | nullWhen the draft was last edited
has_draftbooleanTrue 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 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 for prompting and runtime behavior.

FieldTypeDescription
namestringRequired. 1–60 characters after NFKC normalization and trimming, unique on the agent ignoring case. No line breaks, control characters, or invisible characters
numberstringRequired. 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 when the call starts
platform_okboolean | nullRead-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). 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:

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

codeExtra keysMeaning
tool_has_no_inputstool_name, integration_idAn attached integration 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 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_declaredtool_name, integration_id, argumentsThe 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_collisiontool_name, mcp_server_id, mcp_server_nameAn 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_collisionsee handoffsHandoff problems.
timezone_not_setsuggested_timezone, sourceThe 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 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.

"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

ProductOptimized forAcknowledgement
sparkCost — fast and efficient for simple tasks—
boltSpeed — the lowest-latency tier, balanced for most use cases—
storm-baseIntelligence — complex prompts and reasoning—
storm-base-with-ackIntelligenceSpoken filler (or tick) while thinking
storm-extraDeepest reasoning—
storm-extra-with-ackDeepest reasoningSpoken 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

{
  "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:

FieldTypeDescription
idstringRequired. Matches [a-z][a-z0-9_]{0,39}, unique within the agent
namestringRequired display name, 1–80 characters
instructionsstringRequired, up to 20,000 characters. Supports {{variables}} like prompt
saystring | nullLine spoken word for word when the step is entered, up to 1,000 characters. Supports {{variables}}
toolsarray | nullnull (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
collectarrayUp 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_toolstring | nullAn 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
transitionsarrayOrdered 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_whenstring | nullMakes the step reachable from any step when this holds (the model decides)
voicestring | nullVoice 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

[{"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:

{
  "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:

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"

List agents

cURL
curl https://api.thunderphone.com/v1/agents \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Python
agents = requests.get(
    "https://api.thunderphone.com/v1/agents",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
).json()
Node.js
const agents = await fetch("https://api.thunderphone.com/v1/agents", {
  headers: { Authorization: `Bearer ${process.env.THUNDERPHONE_API_KEY}` },
}).then((r) => r.json());

Returns an array of Agent objects, sorted by created_at descending.


Create an agent

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
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()
FieldTypeRequiredDescription
namestringyes1–255 chars
promptstringyesSystem prompt
voicestringyesVoice name from /v1/voices (e.g. john). Entitled organizations can also pass custom:<public_id> for a ready custom voice, and an agent that does not process PHI can pass an ElevenLabs voice (elevenlabs:<voice_id>); anything else is rejected with "Unsupported voice option."
primary_languagestringnoDefaults to en
additional_languagesarray of stringnoISO 639 (es) or BCP-47 (es-ES) codes
native_voice_switchingbooleannoUse a native catalog voice, matched by gender, after switching to an additional language. Default false
language_switchingstringnoask_first (default) or follow_caller. See Deployed fields
productstringnoDefaults to spark
thinking_levelstringnoIgnored for Storm tiers (derived from product)
background_trackstringnoTrack name or "NONE"
extraction_fieldsarraynoComplete replacement list of structured fields to extract after each retained call. See Deployed fields for the item schema
extraction_blocks_completionstringnoexcept_transfers (default), always, or never; controls customer completion delivery only
outcome_labelsarraynoComplete replacement list of call-report outcome labels; [] restores Success / Failure. See Deployed fields
report_instructionsstringnoInstructions for the call report's prose fields
timezonestringnoThe agent's time zone; see Deployed fields. Optional, but an agent without one lists timezone_not_set in warnings
business_hoursobject | nullnoThis agent's reporting hours; see Deployed fields
acknowledgement_prompt_modestringnoauto or manual. Only meaningful for Storm-with-ack products
acknowledgement_promptstringnoRequired if acknowledgement_prompt_mode="manual"
acknowledgement_feedback_modestringnoacknowledgement (default) or tick
inbound_speak_orderstringnoDefaults to agent_first
outbound_speak_orderstringnoDefaults to caller_first
voicemail_actionstringnoprompt (default), hangup, or message
voicemail_messagestringno≤ 2000 chars
silence_interval_secondsintegerno10–120, default 15
silence_max_checkinsintegerno1–10, default 3
silence_checkins_enabledbooleannoDefault true
silence_hangup_secondsintegerno60–3600, default 600; used when check-ins are disabled
max_call_duration_secondsinteger or nullno60–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_secondsinteger or nullnoRinging 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_enabledbooleannoDefault false
typing_sounds_enabledbooleannoPlay a soft keyboard sound while a custom tool runs. Default false
typing_soundstringnokeys_and_mouse, mechanical, desktop, laptop, office, or quiet_room. Default keys_and_mouse
filler_phrases_enabledbooleannoLong-wait updates on slow tools and replies. false stays quiet after the tool-start line. Default true
tool_start_linestringnoalways, only_if_slow, or never. Default always
play_sound_assetstringno"" (none), chime, ding, or beep
speak_uninterruptible_enabledbooleannoLegacy compatibility field with no current runtime effect. Default false
send_email_enabledbooleannoOffer the built-in email tool when a recipient is configured. Default false
send_email_recipientstringnoFixed email recipient chosen by the agent owner; the model cannot change it
calculator_enabledbooleannoOffer the built-in calculate tool for exact arithmetic. Default false
send_sms_enabledbooleannoOffer send_sms when an eligible customer-connected sender is available. Default false
sms_from_numberstringnoE.164 fallback sender owned by the organization and confirmed SMS-capable by Twilio or Telnyx
sms_allowed_recipientsarray of objectnoUp 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_recipientsbooleannoPermit 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_destinationsarray of objectnoUp to 50 {name, number} transfer numbers. Default []: the agent can't transfer calls
sms_followup_enabledbooleannoSend one eligible post-call SMS. Default false
sms_followup_templatestringnoPlain-text post-call template, up to 1600 characters
consent_announcement_enabledbooleannoDefaults to true. New agents must be created with it enabled; disabling later requires the attestation workflow where applicable
consent_announcement_textstringnoNon-blank call-start announcement text
audio_context_modestringnofull (default) or reduced
watchdog_enabledbooleannoDefault false
additional_audio_contextboolean | nullnoOverride whether the last few caller-audio turns are included. null keeps the direction-based default
widget_enabledbooleannoDefaults to true
split_testing_enabledbooleannoDefault false
integration_idsarray of UUIDnoLink reusable integrations by id
knowledge_base_idsarray of UUIDnoAttach knowledge bases
knowledge_document_idsarray of UUIDnoAttach individual knowledge documents
mcp_server_idsarray of UUIDnoAttach MCP servers
reusable_instruction_idsarray of UUIDnoAttach reusable instructions
call_summary_enabledbooleannoGenerate a post-call summary. Default false

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


Retrieve an agent

cURL
curl https://api.thunderphone.com/v1/agents/12 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Python
agent = requests.get(
    f"https://api.thunderphone.com/v1/agents/{agent_id}",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
).json()

Returns 200 OK with an 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. Sending a value identical to the deployed value clears that field's draft (so manually reverting an edit removes the pending change).

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
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()

Accepts the same fields as create, 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 — 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:

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

cURL
curl -X POST https://api.thunderphone.com/v1/agents/12/deploy \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Python
agent = requests.post(
    f"https://api.thunderphone.com/v1/agents/{agent_id}/deploy",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
).json()

Returns 200 OK with the updated 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.

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

Returns 200 OK with the 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.

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.

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"

Returns 201 Created:

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

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:

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

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

cURL
curl https://api.thunderphone.com/v1/agents/12/consent-attestations?locale=en \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
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:

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"}'
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": ""
}
StatusCondition
201Attestation recorded (POST)
400affirmed 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
403Caller 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.

cURL
curl "https://api.thunderphone.com/v1/phi-designation-attestation?agent_id=12" \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
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
}
StatusCondition
201Attestation recorded (POST)
200The agent already has an active attestation (POST)
400Unknown agent_id, accepted was not true, locale is blank, text_version is not accepted, or statement_sha256 does not match the canonical statement
403API key, or caller is not an org admin (POST)
409The organization's BAA has no designation clause, so there is nothing to attest

Delete an agent

cURL
curl -X DELETE https://api.thunderphone.com/v1/agents/12 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Python
requests.delete(
    f"https://api.thunderphone.com/v1/agents/{agent_id}",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
)

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

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
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()
FieldTypeRequiredDescription
namestringnoDefaults to "<original name> (Copy)"

Returns 201 Created with the new 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.

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
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()
FieldTypeRequiredDescription
target_org_idintegeryesFor mode="move" it must differ from the source org id
modestringnocopy (default — keep source intact) or move (re-home the agent)
namestringnoOverride the new agent's name

copy makes the same copy as Duplicate, 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:

{
  "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
  }
}

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.

cURL
curl 'https://api.thunderphone.com/v1/agents/12/versions?limit=20' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
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()
Query paramDefaultDescription
limit20Max results. Values outside 1–100 are clamped

Response:

{
  "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"]
    }
  ]
}
FieldTypeDescription
idintegerVersion row id
revisioninteger1-based, increments per deploy
created_by_idinteger | nullUser id that deployed the revision
snapshotobjectFull deployed config at this revision
changed_fieldsarray of stringFields 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

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..."}'
Response
{
  "detected_language_code": "es",
  "detected_language_name": "Spanish",
  "mismatch": true,
  "provider": "<provider id>"
}

Translate prompt

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"
  }'
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.

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"
  }'
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.

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."}
      ]
    }
  }'
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.

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": []
  }'
FieldTypeDescription
promptstringRequired. The prompt text to analyze. May be empty. Max 100,000 characters.
primary_languagestringLanguage code to check against.
additional_languagesstring[]Additional language codes to check against.
voicestringVoice to check language coverage against.
integration_idsinteger[]Integrations to treat as connected.
mcp_server_idsinteger[]MCP servers to treat as connected.
integration_public_idsstring[]The same as integration_ids, by the integration id the agent and integration endpoints return. Ignored when integration_ids is sent.
mcp_server_public_idsstring[]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.

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 idSeverityMeaning
languages-not-configuredwarningThe 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-mismatchwarningAt least 60% of the prompt is written in params.detected, not the primary language params.primary.
tool-actions-without-toolswarning or infoThe 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-declaredwarningThe 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-guidancewarningThe prompt gives no path to a human, transfer or callback.
missing-consent-or-privacy-guidanceinfoThe 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 with playable samples:

curl 'https://api.thunderphone.com/v1/voices?languages=en,es' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
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 paramDescription
languagesComma-separated codes — only voices supporting all of them are returned
categoryFilter by voice category
genderFilter 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.