Telephony call control (DTMF, transfer, voicemail/AMD) — Twilio and Telnyx
Scope: DTMF in/out, warm/cold transfer (tool-callable), voicemail / answering-machine
detection strategy and leave-message flow. Related: docs/providers/twilio.md (Media Streams wire protocol, request
validation), docs/providers/telnyx.md (Telnyx media streaming wire protocol), docs/providers/g711.md (codec).
Researched: 2026-10-08. Every fact below was read from the pages listed under Sources on that date. Anything not
confirmed on a fetched page is marked UNVERIFIED. Statements labelled "Proposal" or "Brisvox design" are engineering
choices, not documentation claims. This is call control (REST/webhooks next to the media WebSocket), not an
STT/LLM/TTS provider.
Scope boundary (from twilio.md): brisvox core is the WebSocket SERVER of the media stream and does not serve TwiML or
webhooks. Everything on this page that is REST (update call, participants, send_dtmf, transfer) is an OUTBOUND
HTTPS call that a brisvox tool/adapter makes (reqwest 0.13.5, already in the workspace Cargo.toml); everything that is
a callback (status callbacks, AMD results, conference events, Telnyx webhooks) is served by the user's HTTP app or by an
optional brisvox webhook module (not decided here).
0. Capability matrix (what the docs say works)
| Capability | Twilio (Media Streams, <Connect><Stream>) | Telnyx (Call Control + media streaming) |
|---|---|---|
| DTMF from caller to bot | Yes: dtmf WebSocket event, bidirectional streams only, inbound direction only | Yes: dtmf WebSocket frame (dtmf.digit, occurred_at); webhook call.dtmf.received is documented as a Gather result |
| DTMF from bot to far end, over the stream | NO (documented: not supported outbound) | NO (only media/mark/clear are accepted; no DTMF frame documented) |
| DTMF from bot to far end, other route | REST update call with TwiML <Play digits="..."> (replaces the TwiML, see 2.2) | REST POST .../actions/send_dtmf (no webhooks; interplay with an active stream not documented) |
| Cold transfer | REST update call with <Dial> TwiML (or <Connect action> fallthrough); <Refer> only for SIP legs | POST .../actions/transfer (documented description: original leg stays active if the transfer fails) |
| Warm transfer | Conference + Participants API, or <Dial><Number url=...> whisper screening | Conference commands, or Dial with link_to + Bridge; refer for SIP REFER |
| AMD | Calls API / Participants API / <Dial><Number> / <Dial><Sip>; AnsweredBy incl. machine_end_beep | answering_machine_detection on Dial/Transfer: detect, detect_beep, detect_words, greeting_end, premium |
| AMD price | $0.0075 per call | $0.002 per call (standard), $0.0065 per call (premium) |
| Idempotency key on commands | none documented (UNVERIFIED) | command_id on every command |
1. Twilio
1.1 REST basics (auth, encoding)
- Docs: https://www.twilio.com/docs/usage/requests-to-twilio (fetched 2026-10-08).
- Auth: HTTP Basic. "use the Account SID as the HTTP Basic authentication username and the Auth Token as the password"
(page wording). Alternative: API key SID as username and API key secret as password; the page says to prefer API keys for
applications unless a reference says to use Account SID + Auth Token. Docs' own curl examples use
-u $TWILIO_API_KEY:$TWILIO_API_SECRET. Brisvox: support both; never log the token/secret (same rule astwilio.md). - Base URL:
https://api.twilio.com/2010-04-01/(all examples; the page does not state it as a standalone rule); resource URLs end in.json. - POST bodies:
application/x-www-form-urlencodedormultipart/form-data("Twilio APIs expect ..."). Repeated parameters (e.g. severalStatusCallbackEvent): the Call create page says "Send multiple values as separate parameters" (page wording: "use a separate parameter for each"). - Rate limits / concurrency limits / error body format: NOT covered on the fetched pages (UNVERIFIED). Error bodies in
samples have
error_codeanderror_messagefields only. - Idempotency: no idempotency header or key is documented for Calls or Participants (UNVERIFIED). Brisvox must provide at-most-once itself (engine rule "SideEffect tools never run twice"); after a timeout, GET the call before any retry.
1.2 Call resource: create, update, fetch
Docs: https://www.twilio.com/docs/voice/api/call-resource (and .md), https://www.twilio.com/docs/voice/tutorials/how-to-modify-calls-in-progress (fetched 2026-10-08).
- Create:
POST https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Calls.json. RequiredTo,From. Exactly the documented example:
Create parameters relevant here (page table):curl -X POST "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Calls.json" \ --data-urlencode "Url=http://demo.twilio.com/docs/voice.xml" \ --data-urlencode "To=+15558675310" \ --data-urlencode "From=+15017122661" \ -u $TWILIO_API_KEY:$TWILIO_API_SECRETUrl(absolute URL returning TwiML),Twiml("Max 4000 characters."; page wording: "If bothtwimlandurlare provided thentwimlparameter will be ignored", i.e.Urlwins on Create),ApplicationSid,Method(GET|POST, default POST),FallbackUrl/FallbackMethod,StatusCallback/StatusCallbackMethod(default POST; "Without events, onlycompletedis sent"),StatusCallbackEvent(initiated,ringing,answered,completed; defaultcompleted),Timeout(default 60, max 600; a 5-second buffer may be added),TimeLimit,SendDigits(up to 32 characters; page wording: "If bothSendDigitsandMachineDetectionparameters are provided, thenMachineDetectionwill be ignored"),Recordetc., and the AMD set in 1.7. Dial-out result fields:sid(CA...),status,direction,answered_by,parent_call_sid, ... - Update (redirect / hang up a LIVE call):
POST https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Calls/{Sid}.json(Sid= 34 chars, starts withCA). Page description (paraphrase; the exact earlier-quoted sentence "Modifies an active call. You can redirect an in-progress call to new TwiML or terminate it." was NOT found verbatim, the page says "Updating a Call allows you to modify an active call" and that a POST to a Call instance lets you redirect an in-progress call or terminate it). Parameters (page table):Param Meaning UrlAbsolute URL returning TwiML; called with Methodwhen the call connectsMethodGET or POST, default POST (ignored if application_sidpresent)TwimlTwiML used without fetching Url; mutually exclusive withUrl(page wording: "Twiml and url parameters are mutually exclusive")StatuscanceledorcompletedFallbackUrl,FallbackMethodused on error fetching/executing the TwiML at UrlStatusCallback,StatusCallbackMethodstatus updates; to change StatusCallbackof a call "it is required to set the Url in the same statement"TimeLimitmax call duration in seconds The maximum length of Twimlon UPDATE is not stated (only the Create page says 4000 characters): UNVERIFIED, keepupdate TwiML under 4000 characters or use Url.Example of the documented shape (inline TwiML update; the docs show this as SDK code, the curl form below is derived from the Create example and the parameter table, not a verbatim sample):
Response: the updated Call resource (fields:curl -X POST "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Calls/$CALL_SID.json" \ --data-urlencode "Twiml=<Response><Dial>+905551112233</Dial></Response>" \ -u $TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKENaccount_sid, answered_by, api_version, caller_name, date_created, date_updated, direction, duration, end_time, forwarded_from, from, from_formatted, group_sid, parent_call_sid, phone_number_sid, price, price_unit, queue_time, sid, start_time, status, subresource_uris, to, to_formatted, trunk_sid, uri).statusvalues:queued, ringing, in-progress, completed, busy, failed, no-answer, canceled. Error codes for updating a completed/unknown call: not listed on the page (UNVERIFIED). The adapter therefore does not rely on one status code when it ends a warm transfer's agent leg: after a 4xx other than 404 it reads the leg and treats a final status as "already over", and ends a queued or ringing leg withStatus=canceled(the update table listscanceledorcompleted). - Semantics (tutorial page): the request "instructs Twilio to make an immediate change [to] the TwiML document that Twilio is
using in a phone call". Not stated: what happens to the verb currently running (the page says nothing). Hang up:
Status=completed("To end a phone call by your choice, pass acompletedstatus to a CallSid in progress"). The page also says Twilio ends a call on<Hangup>or when it runs out of TwiML documents to process. - Parent/child (call-resource page, paraphrase of the "modifying active calls" notes): redirecting an active call to another phone number makes Twilio create "an entirely new Call instance for that new phone number"; the original call is the parent call, and each additional dialed number is a child call, each with its own Call SID.
A parent call executing a
<Dial>"is considered in-progress by Twilio" even after redirection, so "you must useStatus=completedto end it." - Fetch:
GET https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Calls/{Sid}.json.
1.3 What a REST update does to a bidirectional Media Stream
This is the central unknown for DTMF and transfer on Twilio.
- Documented:
<Connect><Stream>creates the bidirectional stream; "Twilio doesn't execute subsequent TwiML instructions" and "Twilio executes the remaining TwiML instructions only after your server closes the WebSocket connection" (stream TwiML page). The overview page: "You can stop a bidirectional Media Stream by closing the WebSocket connection from your server or by ending the Call." The messages page says "the only way to stop a Stream is to end the call" (the contradiction is recorded intwilio.md). The stream TwiML page says "You can also update the call with TwiML instructions using the Update a Call resource" (page wording); it does not say what happens to the Stream in that case (our reading; no such sentence is quoted from the page). The general<Connect>page: "If there is no further verb, Twilio will end the phone call", and "If you do not provide anactionURL,<Connect>will finish and Twilio will move on to the next TwiML verb in the document".<Connect action>text: "When the<Connect>verb ends, Twilio sends an HTTP request to this URL"; it does not list Stream-specific parameters. - NOT documented (UNVERIFIED, live-test item 1): whether a REST update with new TwiML closes the stream, sends a
stopframe, closes the socket from Twilio's side, or in what order; whether a new<Connect><Stream>inside the update TwiML is allowed while the old stream is still ending (media-streams overview page: "For bidirectional Streams, you can have only one Stream per Call"; the<Stream>TwiML page only says a Streamnamemust be unique per Call). - Safe sequencing rules derived from the documented facts (Brisvox design):
- Never close the WebSocket first when a hand-off TwiML must follow, unless the original TwiML has a
<Connect action>/next verb that does the hand-off: with no further verb the call ends. - So: finish playing the farewell/hand-off audio (utterance-end mark played), call REST update, wait for HTTP 200, then keep
the socket open and treat the next
stop/close as expected (not a user hang-up) for a bounded time (e.g. 5 s), then close. - Pre-planned documented alternative: original TwiML
<Connect action="https://app/after-stream"><Stream .../></Connect>; brisvox closes the socket, Twilio requests theactionURL (UNVERIFIED that closing a Stream triggersaction), the user's app returns<Dial>TwiML chosen from state brisvox has stored (needs shared state keyed byCallSid). Rejected as the default: requires the user's HTTP app and extra state; REST update is self-contained.
- Never close the WebSocket first when a hand-off TwiML must follow, unless the original TwiML has a
1.4 DTMF
Inbound (caller presses keys): see twilio.md ("Protocol"): {"event":"dtmf","streamSid":..,"sequenceNumber":"5","dtmf":{"track":"inbound_track","digit":"1"}},
bidirectional streams only. Overview page (fetched 2026-10-08): "DTMF isn't supported with unidirectional streams." "DTMF is
supported with bidirectional Media Streams only in the inbound direction, from Twilio toward your media server." Digit alphabet
beyond one key and whether the tone audio is also present in the media payload: UNVERIFIED (the messages page does not say). No duration or timestamp is sent with a dtmf event.
Outbound (bot presses keys on the far end, e.g. IVR navigation):
- Over the stream: NOT supported (same overview page: "Sending DTMF outbound from your media server toward Twilio is not supported"; the server may only send
media,mark,clear). - TwiML
<Play digits="...">(https://www.twilio.com/docs/voice/twiml/play, fetched 2026-10-08):digitsallowed characters:0-9,A,B,C,D,W,w,#,*. Lowercasew= 0.5 s pause, uppercaseW= 1 s pause (page prose:www3= 1.5 s before tone 3; the page's own samples are inconsistent betweenwww3andwwww3). No maximum length or tone duration is stated.- The page describes
<Play digits>as playing DTMF tones "during a call" and suggests testing an IVR menu. It does NOT say that the tones go to a particular leg; for dialing a number with an extension the page says use<Number sendDigits>.<Play digits>cannot be used inside<Gather>(a nested<Play>with a file is allowed, digits are not). - Example (verbatim):
<Response><Play digits="www3"></Play></Response>.
- Other digit-sending parameters: Create Call
SendDigits(up to 32 chars) and<Dial><Number sendDigits="wwww1928">(page 'number': plays tones "once the call is answered"; same alphabet0-9 A B C D W w # *). These apply to calls Twilio places, not to an already-running stream. - Consequence for a call that is in a Media Stream: the only documented way to emit DTMF mid-call is to REPLACE the TwiML via Update Call
(1.2). The update ends the
<Connect><Stream>flow in some way (1.3) so after the digits the TwiML must start a NEW stream:
Consequences (design, UNVERIFIED in practice): a new WebSocket upgrade, a new<Response> <Play digits="ww1234#"/> <Connect> <Stream url="wss://agent.example.com/twilio/stream" name="brisvox"> <Parameter name="resume" value="SESSION_ID"/> </Stream> </Connect> </Response>streamSid, a gap in both audio directions, the newstart.customParameters.resumemust be mapped back to the live session by an adapter (CallInfo.metadata["custom.resume"]), and the Twilio signature check applies to the new upgrade as usual. This is heavy; do it only for deliberate IVR navigation. - In-band alternative (Proposal, reliability UNVERIFIED): synthesize the two-tone DTMF waveform as PCM16 8 kHz in the engine, mu-law encode, and
send it as normal
mediaframes with the audio pacer. No Twilio doc says in-band tones sent on the stream are passed to the far end as DTMF or detected by an IVR; the far-end behaviour of G.711 tones is carrier- and IVR-dependent. Parameters to expose as config, tested live: tone ms, gap ms, level. For orientation only: Telnyx's ownsend_dtmfdocumentsduration_millis100-500, default 250 (section 2.2). Frequency table is the ITU keypad standard; this research confirmed only keys 5 (770+1336 Hz) and 8 (852+1336 Hz) via the dtmf_tones repo README, the rest of the table (rows 697/770/852/941, columns 1209/1336/1477/1633) is UNVERIFIED by a fetched source: cross-check with a decoder in a unit test (Goertzel). - Event wiring (brisvox):
WireEvent::Dtmfalready exists (seetwilio.mdtable, "dtmf -> WireEvent::Dtmf"); brisvox delivers it asData::Dtmfto the turn state machine/flows and exposes it to tools (e.g. adtmfmessage in the LLM context: "[caller pressed 5]"). Outbound: a new transport commandSendDtmf { digits }per transport: Telnyx ->send_dtmf; Twilio -> either the REST-update route above or in-band synthesis, selected by config; others -> "unsupported" error.
1.5 Transfer on Twilio
Primitives (all fetched 2026-10-08): TwiML <Dial>, <Number>, <Conference>, <Refer>, <Redirect>; REST Calls update; Conference and Participant resources.
1.5.1 Cold transfer (bot says goodbye, caller is connected to a number)
- REST: Update Call (1.2) with
Twiml=<Response><Dial callerId="+90..." answerOnBridge="true" timeout="30" action="https://app/dial-result">+90555...</Dial></Response>(attributes from the<Dial>page:action,answerOnBridge(default false),callerId,timeoutdefault 30 (5-600, +5 s buffer),timeLimitdefault 14400,hangupOnStar,record). The caller's call becomes the parent;<Dial>creates a child call with its ownCallSid; both legs bill (billing statement not fetched: UNVERIFIED). A plain-text number is the same as nested<Number>; E.164 recommended. callerIdrules (<Dial>page): the dialed party sees the inbound caller's number by default; with<Number>callerIdmay be theToorFromof Twilio's request, a purchased Twilio number or a verified number; for inbound SIP calls it cannot be theTo/From.- Outcome reporting: with
action, Twilio sendsDialCallStatus(completed,answered(conference),busy,no-answer,failed,canceled),DialCallSid,DialCallDuration,DialBridged. Withoutaction, Twilio moves to the next verb; with none the call ends. After the update brisvox is no longer in the media path, so the outcome reaches only the user's HTTP app (or brisvox via a status callback/poll:GETthe child call, finding it needs the call list filter by parent: UNVERIFIED, not fetched). If the dial fails (busy/no-answer/failed), the caller returns to whatever theactionURL says; to put the bot back, theactionhandler must return<Connect><Stream>again (new session withresumeparameter, see 1.4). This is the documented way to support "transfer failed, bot continues". - SIP REFER:
<Refer><Sip>sip:alice@example.com</Sip></Refer>(https://www.twilio.com/docs/voice/twiml/refer, fetched 2026-10-08). "<Refer>is only supported on SIP call legs"; PSTN and Voice SDK legs are not supported and return an error. So it applies only if the call reached Twilio via SIP (the SIP transport), not for PSTN numbers. Action parametersReferCallStatus,ReferSipResponseCode,NotifySipResponseCode. Billing: not stated on that page (a Twilio help text seen in search says SIP REFER is a separately charged feature: UNVERIFIED). <Redirect>only changes the TwiML document ("All verbs after<Redirect>are unreachable and ignored"); not a transfer by itself.
1.5.2 Announced / screened transfer (no conference): whisper on the agent leg
<Dial><Number url="https://app/whisper"> runs TwiML "on the called party's leg after answer, before the parties are connected" (page 'number', fetched 2026-10-08; paraphrase of the page). Only
<Play>, <Say>, <Gather>, <Hangup> are supported there ("Other TwiML verbs aren't supported"); with answerOnBridge on <Dial> the caller keeps hearing ringing while it runs, and that TwiML cannot contain <Dial>.
Use: <Say language="tr-TR" ...> to read a short summary and a <Gather numDigits="1"> "press 1 to accept"; <Hangup/> otherwise (the page: offer them a chance to decline using <Gather> and <Hangup>).
The url is a URL, so the user's HTTP app serves it (summary passed as a query string or looked up by ParentCallSid: design). Turkish <Say> voices (https://www.twilio.com/docs/voice/twiml/say/text-speech, fetched 2026-10-08):
Google Standard-A..E, Wavenet-A..E, Chirp3-HD (Aoede, Charon, Fenrir, Kore, Leda, Orus, Puck, Zephyr), Amazon Polly Neural Burcu-Neural, Polly Standard Filiz. <Say> text limits (same page): Basic 4000 characters,
Polly 3000 (excluding SSML tags), Google 5000 (including SSML tags, newlines and spaces).
1.5.3 Warm transfer (bot or first agent briefs the target, then hands off)
Documented conference primitives:
- TwiML
<Conference>(https://www.twilio.com/docs/voice/twiml/conference, fetched 2026-10-08): attributesmuted(default false),beep(true|false|onEnter|onExit, default true),startConferenceOnEnter(default true; false = muted and hears hold music until a moderator with true joins),endConferenceOnExit(default false),participantLabel(unique in the conference, max 128 chars; duplicate -> error 16025, call not added),waitUrl/waitMethod,maxParticipants(<= 250),record,region(us1,ie1,de1,sg1,br1,au1,jp1),jitterBufferSize(small|medium|large|off, default large),statusCallback,statusCallbackMethod(the page is internally inconsistent about its default: text says POST, table says GET when nowaitUrl: pass it explicitly),statusCallbackEvent(space-separated:start end join leave mute hold modify speaker announcement; the first participant's values win). The conference does not start until two participants join (and at least one hasstartConferenceOnEntertrue); names are namespaced to the account; do not put personal data in the name. Callback parameters:ConferenceSid, FriendlyName, AccountSid, SequenceNumber, Timestamp, StatusCallbackEvent(conference-start|end,participant-join|leave|mute|unmute|hold|unhold|modify|speech-start|speech-stop,announcement-end|fail); participant events addCallSid, Muted, Hold, Coaching, EndConferenceOnExit, StartConferenceOnEnter, ParticipantLabel;participant-leaveaddsParticipantCallStatus,ReasonParticipantLeft. - Conference resource (https://www.twilio.com/docs/voice/api/conference-resource):
GET .../Conferences.json?FriendlyName=NAME&Status=in-progressfinds theConferenceSid(CF...);statusisinit|in-progress|completed; conference rooms "are not directly created from the Programmable Voice API" (create with TwiML<Dial><Conference>or the Participants API);POST .../Conferences/{Sid}.jsonwithStatus=completedends it orAnnounceUrl=announces to everyone. The page says "Starting from September 30th, 2026, the default [of theStatuslist filter] isin-progress" (already past on 2026-10-08): always passStatusexplicitly. Docs recommend thestatusCallbackover polling. - Participants (https://www.twilio.com/docs/voice/api/conference-participant-resource, fetched 2026-10-08; base
https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Conferences/{ConferenceSid}/Participants):- Create:
POST .../Participants.json; requiredFrom,To. Parameters (selection):Label,CallerId,Timeout(5-600, default 60, +5 s),Muted,Beep,EarlyMedia(default true),StartConferenceOnEnter(default true),EndConferenceOnExit(default false),WaitUrl,Record,ConferenceStatusCallback,ConferenceStatusCallbackEvent(defaultstart end),StatusCallback/StatusCallbackEvent(call status),MachineDetection,MachineDetectionTimeout(default 30),MachineDetectionSpeechThreshold,MachineDetectionSpeechEndThreshold,MachineDetectionSilenceTimeout,AmdStatusCallback,AmdStatusCallbackMethod(default POST),CallToken,Region(us1, us2, ie1, de1, sg1, br1, au1, jp1). Response fields:account_sid, call_sid, label, conference_sid, date_created, date_updated, end_conference_on_exit, muted, hold, status, start_conference_on_enter, coaching, call_sid_to_coach, queue_time (create only), uri; participantstatus:queued|connecting|ringing|connected|complete|failed. The sample create response has"status": "queued". Reconstructed request (the page's own curl example was too long to quote; this follows its fields):curl -X POST "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Conferences/$CF_SID/Participants.json" \ --data-urlencode "From=+15017122661" --data-urlencode "To=+905551112233" --data-urlencode "Label=agent" \ --data-urlencode "EndConferenceOnExit=false" -u $TWILIO_API_KEY:$TWILIO_API_SECRET - Update:
POST .../Participants/{CallSid-or-Label}.json(label must be percent-encoded).Hold=true|false,HoldUrl,HoldMethod(default GET),AnnounceUrl,AnnounceMethod(default POST),Muted,BeepOnExit,EndConferenceOnExit,WaitUrl,Coaching,CallSidToCoach. Documented samples (verbatim): hold:--data-urlencode "Hold=true" --data-urlencode "HoldUrl=http://www.myapp.com/hold_music"; announce:--data-urlencode "AnnounceUrl=http://www.myapp.com/announce"; both against.../Conferences/CF.../Participants/CallSid.json. Announce URL media types: the Conference resource page lists MP3, WAV or TwiML; the list of TwiML verbs allowed (<Play>,<Say>,<Pause>,<Redirect>) was NOT confirmed on the participant page: UNVERIFIED. Whether an announcement interrupts audio already playing is not stated. Long files delay playback; announcements of 30 minutes or longer can trigger a request timeout (conference and calls stay connected, participants hear "An application error has occurred"). - Delete (remove the leg from the conference):
DELETE .../Participants/{CallSid}.json-> HTTP 204, no body (the removed participant's call ends or continues per its TwiML: UNVERIFIED which, the page does not say; the Twilio blog tutorial instead ends a leg with Calls updatestatus: completed). - List/fetch: list filters
Muted, Hold, Coaching, PageSize (default 50, max 1000); fetch accepts a Call SID or a label.
- Create:
- Documented warm-transfer shape (Twilio TaskRouter "Call Control Concepts", https://www.twilio.com/docs/taskrouter/contact-center-blueprint/call-control-concepts, fetched 2026-10-08): (1) put the caller on hold (participant update
Hold=true+HoldUrl), (2) create a new TaskRouter task for the transfer target (with the conference SID and original TaskSID) and, once assigned, call agent 2 into the conference with TwiML (the page's route is via TaskRouter, not a bare Participants create), (3) three-way call, (4) remove agent 1's leg via the Participants API, (5) complete the task. Cold-transfer variant on the same page: "it is not important for the addition of the new participant to be completed before the first agent call SID is removed" and "The conference and caller leg will stay up even if no one else is on the conference." - Voice-AI example from Twilio (blog, published 2025-09-08, https://www.twilio.com/en-us/blog/developers/tutorials/product/warm-transfer-openai-realtime-programmable-sip): the caller sits in a conference
(
<Conference startConferenceOnEnter="true" participantLabel="customer" endConferenceOnExit="true">, conference named with the inboundCallSid), the AI is a SECOND conference participant (label "virtual agent") dialed over SIP, the human agent is added with Participants create (label "human agent",earlyMedia: false,callToken), a conferencejoinstatus callback triggers removal of the AI leg by listing participants and updating that leg withstatus: completed. The article says it has no unit tests and that tests are needed before production; it does NOT state anything about a missing briefing/summary announcement or fragile error handling (an earlier version of this file claimed so; not supported by the page). - Problem for brisvox (design analysis): with
<Connect><Stream>the bot is not a conference participant; it is a WebSocket on the caller's own leg. Moving the caller into a conference by TwiML ends that flow (1.3), so the bot cannot brief the agent or stay in a three-way call. Three workable shapes:- Consult-leg pattern (composed from documented primitives only; end-to-end UNVERIFIED): (a) update the caller's call with
<Play loop="0">hold audio (or<Conference>alone withwaitUrl); (b) create a second outbound call to the agent via Calls create withTwiml=<Response><Connect><Stream url="wss://.../twilio/stream"><Parameter name="role" value="consult"/><Parameter name="parent" value="CALLER_CALL_SID"/></Stream></Connect></Response>so a second brisvox session talks to the agent (briefing, "accept?" via speech or DTMF); (c) if accepted, update both calls with<Dial><Conference>SAME_NAME</Conference></Dial>(agentendConferenceOnExit, callerendConferenceOnExit), the consult stream ends; (d) if declined, update the caller back to<Connect><Stream>withresume. Needsanswered_byhandling on the agent leg (voicemail of the agent's phone must not "accept": use AMD from 1.7). - Whisper screening (1.5.2): no conference, simplest, Twilio TTS reads the summary, agent accepts by DTMF. Good default for "warm".
- Bot as a conference participant from the start (Twilio blog shape): needs the bot reachable as a SIP/PSTN endpoint; not researched further.
- Consult-leg pattern (composed from documented primitives only; end-to-end UNVERIFIED): (a) update the caller's call with
- Tool contract (Proposal):
transfer_call { destination, mode: "cold" | "announced" | "warm", summary?, reason? }as a SideEffect tool: never speculative, idempotency key per call+destination, one transfer per call. Result returned to the LLM:{status: "initiated" | "failed", detail}; afterinitiatedthe session ends (cold) or waits for the consult result (warm). The tool must (1) validate E.164 against an allow-list from config (prevents prompt-injection toll fraud), (2) have the bot finish its hand-off sentence and wait for the played-mark (the Twilio adapter notes intwilio.md"Ending the call"), (3) only then call the REST update.
1.6 Status callbacks (Twilio)
- Calls API:
StatusCallback+StatusCallbackEvent(initiated|ringing|answered|completed, defaultcompleted); form-encoded request carrying the standard TwiML request parameters (https://www.twilio.com/docs/voice/twiml, fetched 2026-10-08):CallSid, AccountSid, From, To, CallStatus(page listsqueued, ringing, in-progress, completed, busy, failed, no-answer, canceled;initiatedis NOT in that list although it is a validStatusCallbackEventname),ApiVersion, Direction(inbound|outbound-api|outbound-dial),ForwardedFrom, CallerName, ParentCallSid, CallToken; status callbacks addCallDuration,RecordingUrl/Sid/Duration. <Dial><Number statusCallback ...>(page 'number'): addsParentCallSid,CallStatus,CallDuration(completed only),Timestamp(RFC 2822, UTC),CallbackSource(call-progress-events),SequenceNumber(starts at 0; events may arrive out of order). Child calls are never queued.- Conference callbacks: see 1.5.3. Stream callbacks (
statusCallbackon<Stream>): intwilio.md. - Authenticity: same
X-Twilio-SignatureHMAC-SHA1 scheme astwilio.md("Request validation"); the callback is HTTP(S), so the URL form is the normal one.
1.7 Answering Machine Detection (Twilio)
Docs: https://www.twilio.com/docs/voice/answering-machine-detection and -faq-best-practices (fetched 2026-10-08).
- Where AMD works (FAQ page): Calls API create, Participants API create,
<Dial><Number>,<Dial><Sip>. It does NOT work on Elastic SIP trunking calls,<Dial><Client>,<Dial><Conference>,<Dial><Queue>. Changelog 2022-10-07: Dial and Participants AMD need an account with "Enhanced Programmable SIP Features". Inbound-call AMD: not offered (the pages show only outbound use; not stated explicitly). MachineDetection:Enable(returns as soon as the party is identified; use to connect humans / hang up on machines) orDetectMessageEnd(returnshumanimmediately; for a machine only when the greeting ends, "usually indicated by a beep"; the mode for leaving voicemail).AnsweredByvalues: withEnable:machine_start,human,fax,unknown; withDetectMessageEnd:human,machine_end_beep,machine_end_silence,machine_end_other(also a fallback when a machine was detected butMachineDetectionTimeoutexpired before the greeting ended),fax,unknown(timeout or initial-silence timeout).machine_startis not defined further on the page. FAQ: treat anymachine_end_*as "machine, greeting finished" unless you must tell them apart.- Timing parameters (Create Call / AMD pages):
MachineDetectionTimeoutdefault 30 s (AMD page range 3-59; the<Dial><Number>page says 3-60; the Create Call page lists no range: inconsistent, UNVERIFIED which is right; stay within 3-59), only relevant forDetectMessageEndper the FAQ;MachineDetectionSpeechThreshold1000-6000 ms default 2400 (higher = fewer false machine, slower machine detection);MachineDetectionSpeechEndThreshold500-5000 ms default 1200 (higher helps short voicemails, delays human detection; lower risks false human);MachineDetectionSilenceTimeout2000-10000 ms default 5000 (initial silence beforeunknown). FAQ tuning hints: greetings of individual humans (residential/mobile) typically under 1800 ms; business 1800-3000 ms; machines over 3000 ms; recommended values for residential/mobile callees SpeechThreshold 1800-2000 and SpeechEndThreshold 1400-1500; for business callees SpeechThreshold 1800-3000 and SpeechEndThreshold 1400-1500. "30 seconds is frequently not enough time" for business voicemail (FAQ); lowMachineDetectionTimeoutincreasesunknown. - Sync vs async: default is synchronous: Twilio blocks the call until the verdict (the callee hearing silence for "several seconds" is our description, UNVERIFIED wording), then runs the TwiML; with
Urlit sends a webhook containing the decision (exact form-field name in that webhook: described asAnsweredByin the AMD docs; the TwiML request-parameter page does not list it: UNVERIFIED). Async (AsyncAmd=true, Calls API): callee connected immediately, TwiML runs, result posted toAsyncAmdStatusCallback(+AsyncAmdStatusCallbackMethod; AMD page says only POST, the Create page says GET|POST: UNVERIFIED which is right). Whether Participants API,<Dial><Number>,<Dial><Sip>are async only: UNVERIFIED (not stated on the AMD page; the 2022 changelog says AMD "will run in the background while the call connects" for those). Async callback fields:CallSid,AccountSid,AnsweredBy,MachineDetectionDuration(ms). The page shows no example payload (an earlier version of this file said it did); illustrative, constructed payload only:CallSid=CA...&AccountSid=AC...&AnsweredBy=machine_end_beep&MachineDetectionDuration=4200. - Typical latency (FAQ): results "within ~4 seconds after the call was answered" with default settings (an average); webhook round trip is the most common cause of slowness. Accuracy: FAQ "close to 100% accurate in US destinations with default settings" for
DetectMessageEnd; international accuracy "may be slightly reduced". - Forked-stream interaction (AMD page, key for brisvox): async AMD uses one of the four forked audio streams per call; sync AMD uses none. The AMD page says Media Streams, SIPREC and Real-Time Transcription should be started "only after" Async AMD finishes, as indicated by the
AsyncAmdStatusCallback. The stream overview page lists AMD among features sharing the four-track limit (that limit is stated for unidirectional streams); bidirectional streams are limited to one per call. Whether async AMD can run concurrently with a bidirectional<Connect><Stream>is therefore at best discouraged by the docs (UNVERIFIED if it errors). - Recommended Twilio voicemail pattern for brisvox (Proposal built on the above): outbound Calls create with
MachineDetection=DetectMessageEnd(sync),MachineDetectionTimeout=45(below 59),MachineDetectionSilenceTimeoutdefault,Url=https://app/voicewhose handler readsAnsweredByand returns<Connect><Stream url=...><Parameter name="answered_by" value="{AnsweredBy}"/></Stream></Connect>; brisvox getsCallInfo.metadata["custom.answered_by"]atstart, so no AMD plumbing is needed inside brisvox core. Humans: conversation starts about 0-4 s after pickup (sync-silence cost). Machines: wait formachine_end_*, then speak the message. Async variant to cut the silence:AsyncAmd=true, TwiML<Pause length="N"/>then<Connect><Stream>is NOT recommended (the docs say not to start a stream before the callback; a REST update on callback could start it, behaviour UNVERIFIED). - Cost: $0.0075 per call (https://www.twilio.com/en-us/voice/pricing/us line "Answering machine detection ... $0.0075 per call", fetched 2026-10-08; the 2019 GA blog says charged when enabled and the called party answers, busy/failed not charged; 2022 changelog: "$0.0075 per completed call"). Media Streams are listed at $0.0044 per minute and conference participants "Starting at $0.0018 / participant per min" on the same page.
- Language/countries: the AMD docs do not mention languages or Turkish (UNVERIFIED for Turkish voicemail greetings). A supported-country list was not found (the 2019 GA blog lists no countries; its example number is +1); the 2017 beta post said US and Canada only (old, from a search summary, UNVERIFIED); pricing pages for other countries list the AMD fee, which suggests availability but is not proof. Test with Turkish carriers (Turkcell, Vodafone, Turk Telekom voicemail) live before trusting it.
2. Telnyx Call Control
2.1 REST basics
- Base URL:
https://api.telnyx.com/v2(OpenAPI server; docs exampleshttps://api.telnyx.com/v2/calls/{call_control_id}/actions/answer). Docs: https://developers.telnyx.com/docs/voice/programmable-voice/sending-commands (fetched 2026-10-08). That page itself only covers base URL, auth header, and HTTP status meanings (200, 403, 404, 422); the facts below onclient_state,command_id, response bodies and error codes come from the individual command pages (send-dtmf, transfer-call, bridge-calls, etc.) and the webhooks page. - Auth:
Authorization: Bearer YOUR_API_KEY(API key v2); JSON bodies (Content-Type: application/jsonin the docs' curl). call_control_ididentifies the call leg to control (command pages: "Identifier and token for controlling the call"; the claim that it also routes to the node where the call lives, for lower latency, was NOT found on the fetched pages: UNVERIFIED). Every command takes optionalclient_state(Base64, echoed in all later webhooks) andcommand_id(send-dtmf page: Telnyx ignores any command with the samecommand_idfor the samecall_control_id; the webhooks page adds "within 60 seconds"; this is the idempotency key to use for transfer and DTMF).- Success:
200 {"data":{"result":"ok"}}. Errors:422with an error list (transfer page examples showcode,title,detail; thesource/metamembers are UNVERIFIED); samples:90015invalid Call Control ID,90018call already ended,90034call not answered yet,10004missing parameter,90045"command can't be issued when media streaming is used"; generic failure10007. Dial lists400, 422, 429 (rate limited), 500, 503. Rate limit values: not found (UNVERIFIED). - Commands exist (index page):
send_dtmf,send_sip_info,transfer,bridge,refer,hangup,streaming_start,streaming_stop,playback_*,speak,gather_*,record_*,answer,reject,fork_*,transcription_*,client_state_update; conference commandshold,unhold,join,leave,mute,unmute,play,speak,update. No call-level hold command is listed (hold exists on conferences).
2.2 DTMF
Docs: https://developers.telnyx.com/api-reference/call-commands/send-dtmf (fetched 2026-10-08).
- Send:
POST https://api.telnyx.com/v2/calls/{call_control_id}/actions/send_dtmf. Description: "Sends DTMF tones from this leg. DTMF tones will be heard by the other end of the call." Body:digits(required; "Valid digits are 0-9, A-D, *, and #. Pauses can be added using w (0.5s) and W (1s)", example1www2WABCDw9),duration_millis(100-500, default 250, "for how many milliseconds each digit will be played in the audio stream"),client_state,command_id. Response{"data":{"result":"ok"}}. No webhooks. No maximum number of digits stated (UNVERIFIED).
Whether this command is rejected (curl -X POST https://api.telnyx.com/v2/calls/$CALL_CONTROL_ID/actions/send_dtmf \ -H "Authorization: Bearer $TELNYX_API_KEY" -H "Content-Type: application/json" \ -d '{"digits":"1www2","duration_millis":250,"command_id":"UUID"}'90045) or works while a media stream is active on the same leg: not documented (UNVERIFIED, live-test item 4). Separate commands exist for SIP INFO (send_sip_info, not fetched) and conference participants (/v2/conferences/{id}/actions/send_dtmf, not fetched).send_digits_on_answer(max 64 chars,0-9 A-D w W * #) on Dial/Transfer; atomay end with,digits(+18004247767,200); an explicitsend_digits_on_answeroverrides digits appended toto. - Receive over the stream:
{"event":"dtmf","stream_id":..,"occurred_at":..,"sequence_number":"5","dtmf":{"digit":"1"}}(digits0-9 * # A B C D; may arrive out of order; order byoccurred_at;occurred_atis optional in the schema; full schema intelnyx.md). - Receive as webhook:
call.dtmf.receivedexists withdata.event_type = "call.dtmf.received",data.payload.digit(string), pluscall_control_id, call_leg_id, call_session_id, client_state, connection_id, from, to(source: official SDK modeltelnyx-pythontypes/call_dtmf_received.py, read 2026-10-08). The Voice API webhooks page describes it as "A DTMF digit was received", fired when the caller presses keys during Gather using audio or Gather using speak (paraphrase; the exact earlier-quoted sentence was not verified verbatim), and the Gather command docs list it as an expected webhook; it is NOT documented as a general always-on DTMF event. For a streamed bot, use thedtmfstream frame. - Mute:
mute_dtmf(none|both|self|opposite) on Transfer/Bridge: DTMF is not passed through but DTMF webhooks still fire (Bridge page wording).
2.3 Transfer on Telnyx
- Transfer:
POST https://api.telnyx.com/v2/calls/{call_control_id}/actions/transfer(https://developers.telnyx.com/api-reference/call-commands/transfer-call, fetched 2026-10-08). Onlytorequired (DID or SIP URI;;secure=true|srtp|dtls;,digitssuffix allowed). Selected body fields:from(default the original call'sto; E.164),from_display_name(<=128),audio_url/media_name(WAV/MP3 played to the destination on answer, before bridging; mutually exclusive),send_digits_on_answer,early_media(default true),timeout_secs(default 30, 5-600),time_limit_secs(default 14400, 30-14400),park_after_unbridge(self),answering_machine_detection(defaultdisabled; enum below) +answering_machine_detection_config,custom_headers,sip_headers(onlyUser-to-User),sip_transport_protocol,sip_region,preferred_codecs,media_encryption,target_leg_client_state,client_state,command_id,webhook_url(+method),mute_dtmf,record*. Behaviour: bridges the caller (Leg A) to a NEW leg (Leg B) toto. Webhooks:call.initiated,call.bridged(to Leg B),call.answeredorcall.hangup, and AMD events when requested. On a failed transfer the docs say acall.hangupfor Leg B is sent; the original call remains active and can receive further commands (incl. a transfer to an alternate destination). Timeouts/limits producecall.hangupwithhangup_causetimeoutortime_limit. After the bridge ends, the leg hangs up unlesspark_after_unbridge: "self". Response{"data":{"result":"ok"}}.curl -X POST https://api.telnyx.com/v2/calls/$CALL_CONTROL_ID/actions/transfer \ -H "Authorization: Bearer $TELNYX_API_KEY" -H "Content-Type: application/json" \ -d '{"to":"+905551112233","from":"+908501234567","timeout_secs":30,"command_id":"UUID"}' - Media stream interplay: the transfer page lists
90045(media streaming in use) among its 422 examples and says nothing else about streaming. The same 90045 example ("This command can't be issued when media streaming is used.") appears on the streaming_start page. Which commands are blocked by an active stream is NOT stated (UNVERIFIED, live-test item 4). Iftransfer/bridgeis rejected with90045, issuePOST .../actions/streaming_stopfirst (stream_idoptional; omitted = all streams of the call; webhookstreaming.stopped), wait for it, then transfer; on failure,streaming_startagain. Whether the WebSocket gets astopframe afterstreaming_stop: the streaming_stop page does not describe it; the WebSocket reference says Telnyx sendsstop"when it stops media streaming for the call" (the media-streaming guide: when the call ends, after thestreaming.stoppedwebhook): treat the exact ordering as UNVERIFIED. Streams: "Currently only one streaming/fork operation is supported per call" (media-streaming page). - Bridge two legs:
POST .../calls/{call_control_id}/actions/bridgewith bodycall_control_id(the other leg; mutually exclusive withqueue/video_room_id),park_after_unbridge,hold_after_unbridge,play_ringtone,ringtone,mute_dtmf,prevent_double_bridge,record*,client_state,command_id. Webhookscall.bridgedfor both legs. Errors incl.90034not answered. - Dial a second leg linked to the first:
POST https://api.telnyx.com/v2/callswith requiredconnection_id,to,from;link_to(call control id to share a session with),bridge_intent(requireslink_to; overwritesfromwith the linked call'sfrom),bridge_on_answer(requireslink_to),prevent_double_bridge,park_after_unbridge(requireslink_to); responsedata:call_control_id, call_leg_id, call_session_id, is_alive (always false for dial), client_state, record_type:"call", .... Dial also takes the streaming fields (stream_url,stream_track,stream_bidirectional_mode,stream_auth_token, ...) so a CONSULT leg can be streamed to a second brisvox session (same consult pattern as Twilio 1.5.3 shape 1). Fully documented parameters are in the Dial page; shape not live-tested. - Conference:
POST /v2/conferences(call_control_id,namerequired;start_conference_on_createdefault true;hold_audio_url/hold_media_name;max_participants2-800 default 250 (the page's own error examples conflict on this range, UNVERIFIED);duration_minutes; conferences expire after all leave or 4 hours; statusinit|in_progress|completed;end_reasonall_left|ended_via_api|host_left|time_exceeded),POST /v2/conferences/{id}/actions/join(call_control_idrequired;hold,hold_audio_url,mute,start_conference_on_enter(default false),end_conference_on_exit,soft_end_conference_on_exit,supervisor_rolebarge|monitor|none|whisper,whisper_call_control_ids,beep_enabled),POST /v2/conferences/{id}/actions/hold(call_control_idsempty = all;audio_url/media_name). Webhooks (create-conference page)conference.created,conference.participant.joined|left,conference.ended,conference.recording.saved,conference.floor.changed. A warm transfer on Telnyx is therefore: conference the caller, hold the caller, dial and join the agent, brief, unhold, hang up the bot leg. As with Twilio, the bot-as-stream sits on the caller's leg, whether it survives conference join is UNVERIFIED. - SIP REFER:
POST .../actions/referwithsip_address(required SIP URI),client_state,command_id,custom_headers,sip_auth_username/password,sip_headers(User-to-Useronly). Webhookscall.refer.started,call.refer.completed,call.refer.failed(the earlier-noted payload fieldsip_notify_responsewas NOT found on the refer page: UNVERIFIED). Whether the call must be answered: not stated (90034is a listed generic error). The SIP trunking "External transfers" page describes blind (SIP REFER, "Immediate transfer without announcement") and attended transfer (hold, dial, announce, then REFER) for SIP endpoints, with a requiredDiversionheader: that page concerns SIP-trunk endpoints and is not directly the Call Control stream bot case. - Telnyx's own AI Assistant product has a warm-transfer-acceptance feature (consult leg, built-in
complete_transfertool, 2-minute decision timeout,private|sharedcontext): it is a different product; recorded only as design reference (https://developers.telnyx.com/docs/inference/ai-assistants/warm-transfer-acceptance). - Price line seen: "Call transfer | $0.10 per invocation" (https://telnyx.com/pricing/voice-api, fetched 2026-10-08); which commands count as a "transfer invocation" is UNVERIFIED.
2.4 AMD on Telnyx
Docs: https://developers.telnyx.com/docs/voice/programmable-voice/answering-machine-detection, Dial and Transfer pages (fetched 2026-10-08).
- Enable with
answering_machine_detectionon Dial (outbound) or Transfer. (An earlier version of this file quoted the page as saying "outbound call creation or inbound call transfer"; that wording was NOT found on the AMD guide, Dial or Transfer pages: UNVERIFIED.) Modes:disabled(default),detect,detect_beep,detect_words,greeting_end,premium("recommended" on the guide),premium_ios_call_screening_detection.Mode Webhooks resultvaluesdetectcall.machine.detection.endedhuman,machine,not_sure("Recommended to treat as if human answered")detect_beepdetection.ended; call.machine.greeting.endedonly if a beep is detectedgreeting: beep_detecteddetect_wordsdetection.ended; greeting.ended on beep or after a 30 s beep-detection window greeting: beep_detectedornot_sure(timeout)greeting_endboth; Transfer page: greeting.ended when the greeting ends "with a beep or silence" greeting: endedpremiumcall.machine.premium.detection.ended,call.machine.premium.greeting.endeddetection: human_residence,human_business,machine,silence,fax_detected,not_sure; greeting:beep_detected,no_beep_detectedpremium_ios_call_screening_detectionadds call.machine.premium.call_screening.detectedscreening; greetingprompt_endedPayload (SDK models call_machine_detection_ended.pyandcall_machine_greeting_ended.py, read 2026-10-08):data.event_type,data.id,data.occurred_at,data.record_type:"event",data.payload.{call_control_id, call_leg_id, call_session_id, client_state, connection_id, from, to, result}(all optional in the SDK);resultLiteral for detection.ended: `humanmachine not_sure ; for greeting.ended:beep_detectedOrder: call.initiated->call.answered-> detection result -> greeting result; callee hang-up givescall.hangupat any point. Premium ordering: if a beep is heard beforedetection.ended, the greeting webhook is sent first (UNVERIFIED on the current guide fetch, which shows the general order and the iOS order only). - Config
answering_machine_detection_config(Dial page defaults):total_analysis_time_millis3500,after_greeting_silence_millis800,between_words_silence_millis50,greeting_duration_millis3500,initial_silence_millis3500,maximum_number_of_words5,maximum_word_length_millis3500,silence_threshold256,greeting_total_analysis_time_millis5000,greeting_silence_duration_millis1500, beep tuning (beep_detection_profileboth|freq_only,beep_min_frequency_hz200-4000,beep_max_frequency_hz,beep_min_tone_duration_millis50-5000,beep_spectral_*),prompt_end_timeout_millis(Dial/Transfer pages: default 5000, 1000-120000; the guide says 30000 default; iOS screening only). Forpremiumonlytotal_analysis_time_millisandgreeting_duration_millisapply. The AMD guide quotes premium default analysis timeout 30 s (resultnot_sureon expiry) while the Dial and Transfer pages list 3500 for the config default: inconsistent (UNVERIFIED which applies to premium). The field descriptions on the Dial page make the heuristics explicit (useful design reference for an in-engine detector): initial silence overinitial_silence_millis(3500 ms) = machine; a greeting longer thangreeting_duration_millis(3500 ms) = machine; more thanmaximum_number_of_words(5) words = machine; a single word longer thanmaximum_word_length_millis(3500 ms) = machine; silence overafter_greeting_silence_millis(800 ms) after a greeting = human. (These are descriptions of config fields, not a full algorithm spec.) - Cost: standard AMD $0.002 per call, premium $0.0065 per call (https://telnyx.com/pricing/voice-api, fetched 2026-10-08); Media Streaming over WebSockets $0.0035 per minute; Voice API outbound "$0.002 per minute + the SIP Trunking fee".
- Language/Turkish: not documented in the AMD guide (UNVERIFIED). Telnyx streaming/AMD webhooks need a Call Control App connection and a public webhook (UNVERIFIED on fetched pages; design assumption).
2.5 Telnyx webhook mechanics (needed for transfer and answering-machine detection if brisvox consumes them)
Docs: https://developers.telnyx.com/docs/voice/programmable-voice/voice-api-webhooks (fetched 2026-10-08).
- Envelope:
data.{record_type:"event", event_type, id, occurred_at, payload}andmeta.{attempt, delivered_to}. - Signature headers:
Telnyx-Signature-Ed25519(ED25519 signature) andTelnyx-Timestamp(Unix timestamp); the earlier claim "compatible with the Standard Webhooks specification" is NOT on the fetched webhooks page: UNVERIFIED. Verification steps are on a separate Webhook Fundamentals page (not fetched: UNVERIFIED). - Delivery: 2xx = ack; 3xx followed up to 3 redirects; 408, 429, 5xx retried; other 4xx not retried; "webhooks may be delivered more than once" -> dedupe on
data.id;webhook_timeout_secs0-30; failover URLwebhook_event_failover_url; ack "within a few seconds" and process asynchronously (the "few seconds" wording UNVERIFIED on the latest fetch; retry count/interval not specified on the page). Per-call webhook override viawebhook_url(+webhook_url_method) on commands;webhook_urls/webhook_retries_policies(max 5 retries, total <= 60000 ms) are on the Dial page. - Correlate with
call_leg_id;call_session_idgroups related legs (e.g. both sides of a transfer). Ordering guarantees: none documented.
3. Brisvox design for DTMF, transfer and voicemail (Proposal)
3.1 DTMF
- Inbound: already produced by the Twilio adapter (
dtmf->WireEvent::Dtmf) and by the Telnyx adapter (dtmf.digit, order byoccurred_at). The design adds: delivery to the turn manager/flows and to tools as an event with timestamp; a debounce/collector (collect_digits { max, terminator '#', timeout }tool or flow node) with an explicit timeout (engine rule); DTMF while the bot speaks counts as barge-in only if configured; DTMF is NOT run through STT. - Outbound: transport capability flag
can_send_dtmf: Telnyx true (RESTsend_dtmfwithcommand_id); Twiliofalseover the stream, with two opt-in fallbacks (REST update + reconnect, or in-band synthesis); loopback/WebSocket transports: custom message.send_dtmftool = SideEffect, idempotency key per (call, digits, sequence), digits validated against[0-9A-D*#wW]and a length limit (TwilioSendDigits32, Telnyxsend_digits_on_answer64;digitsmax unspecified). - Crates: no crate needed. Hand-write the two-tone generator (a few lines,
no_std-style, reusable buffer) and a Goertzel check in tests. crates.io check (API, 2026-10-08, re-checked in fact-check):dtmf0.1.5 (MIT, decoder, last release 2022-11-04, 9820 total downloads: stale),dtmf_tones1.0.2 anddtmf_table1.1.4 (MIT, frequency tables only, no audio generation per the repo README;dtmf_tonesrepo shows 21 commits, 0 stars, no releases; new, tiny, not adopted).twiml-rust0.1.0 exists (TwiML generator, 180 downloads) but is not needed: TwiML strings here are tiny and must be XML-escaped by a small helper (escape&,<,>,"). reqwest 0.13.5 (MIT OR Apache-2.0, released 2026-09-08) is already a workspace dependency for REST calls.
3.2 Transfer
- Port:
CallControltrait (provider-agnostic):transfer(target, mode, ctx) -> TransferOutcome, implemented for Twilio (TwilioCallControl, REST) and Telnyx (TelnyxCallControl, REST), each with timeouts on every HTTP call, a bounded retry policy that never retries a possibly-applied command (use GET status first on Twilio;command_idon Telnyx), and credentials inSecret. - State machine on the session:
Active -> TransferPending(epoch) -> HandedOff | Failed(reason) -> Active|Ended. While pending, the bot stops generating new LLM turns; the farewell audio must be fully played (mark) before the REST call. - Twilio cold: REST update with
<Dial>(1.5.1). Twilio announced:<Dial><Number url=whisper>(1.5.2). Twilio warm: consult-leg pattern (1.5.3, shape 1) or conference; all UNVERIFIED end to end, hence the pending live check. - Telnyx cold:
transfer; warm: dial linked leg (+ streamed consult) andbridge, or conference; rememberstreaming_stopbefore commands that fail with90045. - Safety (engine invariant: SideEffect never speculative, never twice):
transfer_callruns only after the model's tool call finishes (not speculatively), idempotency key = hash(call id, target); destination allow-list or country allow-list in config; log the target redacted.
3.3 Voicemail strategy
Detection layers, in order of authority:
- Provider AMD when brisvox originates the call (Twilio
DetectMessageEndsync via the user'sUrlwebhook ->answered_bycustom parameter; Telnyxgreeting_endorpremiumvia the Dial request + webhooks). The result must reach the session (Twilio: custom parameter atstart; Telnyx: webhook orclient_state). - In-engine confirmation (always on, cheap): watch the first seconds of the callee audio with VAD/STT: a single long continuous utterance over the Telnyx-documented 3500 ms, many words, or voicemail phrases in the STT text (Turkish phrases such as "mesaj bırakın", "bip sesinden sonra", "sesli mesaj" are a design suggestion, not from a fetched source) overrides
human. A beep cue is only reliably available from the provider (machine_end_beep,beep_detected); an in-engine beep detector would be a new DSP component (not researched). - Fallback when AMD says
unknown/not_sure: treat as human (Telnyx guide:not_sure"Recommended to treat as if human answered" for standard AMD; for premium the docs give no recommendation); Twilio gives no recommendation (UNVERIFIED),fax-> hang up.
Leave-message flow (state machine):
Dialing -> Answered -> Classifying(≤ amd_timeout) ->human: normal conversation; machine:WaitGreetingEnd(≤ greeting_timeout) -> Leaving -> Done.WaitGreetingEndends on Twiliomachine_end_beep|machine_end_silence|machine_end_otheror Telnyxcall.machine.greeting.ended(beep_detectedorended); on a TwilioEnableverdictmachine_startdo NOT speak yet. On Telnyxdetect_words,not_sureafter the 30 s window means speak anyway or hang up (policy flag).Leaving: play ONE fixed message (pre-rendered or synthesized once, Turkish text from config), barge-in disabled, no LLM turn, no STT-driven interruption; wait for the utterance-end mark (MarkPlayed) with a timeout, then hang up. Message length bounded (config, e.g. 20-30 s; voicemail systems cut off: carrier/system limit UNVERIFIED). The message must not reference data the callee did not consent to (compliance note, not a fetched fact).- Provider-side alternative for the message itself: Twilio
<Say language="tr-TR">(voices in 1.5.2) or<Play>; Telnyxspeak/playback_start(not fetched: UNVERIFIED Turkish voices). Prefer brisvox's own TTS for voice consistency and Turkish quality. - Metrics/logging: AMD verdict,
MachineDetectionDuration(Twilio, ms), time from answer to verdict, time from greeting end to first message audio; counted per call outcome (human|machine_message_left|machine_hangup|unknown|fax).
4. Open items to settle with live tests (need accounts, numbers, public wss URL)
- Twilio: effect of REST Update (new TwiML) on an active
<Connect><Stream>: does Twilio sendstop, close the socket, in what order; does a new<Connect><Stream>in the update TwiML start immediately; what does the caller hear in the gap;<Connect action>firing on server close. - Twilio:
<Play digits>tone duration/level and whether the far-end IVR reliably accepts it; does in-band G.711 DTMF sent on the stream reach an IVR (tone length/gap needed). - Twilio: Update Call
Twimllength limit; error codes/status for updating an ended call; whetherUrlupdate triggers a signed request with the usualX-Twilio-Signature. - Telnyx: whether
send_dtmf,transfer,bridgeare blocked (90045) while a media stream is active; which exact commands; effect of transfer on the stream (stopframe?); whetherstreaming_stop+transfer+streaming_startis quick enough. - Twilio consult-leg and conference warm transfer end to end (hold/unhold,
Holdparticipant update on a leg created by<Dial><Conference>, drop of the bot leg, ConferenceSid discovery byFriendlyName). - AMD: sync
DetectMessageEndlatency distribution on Turkish carriers and Turkish voicemail greetings (Twilio and Telnyx), false-human/false-machine rate,machine_end_otherfrequency, behaviour with mobile "arama yönlendirme" announcements; whether async AMD can coexist with a bidirectional stream; whether Turkey is a supported AMD destination at all. - Twilio
AnsweredByform-field name in theUrlwebhook and theAsyncAmdStatusCallbackMethodGET support (docs disagree). - Telnyx: premium AMD payload schemas and default
total_analysis_time_millis;call.dtmf.receivedoutside Gather. - Billing: Twilio parent+child leg billing for
<Dial>transfers; Telnyx "Call transfer $0.10 per invocation" scope; conference participant minutes. - Rate limits for the REST endpoints on both providers.
Sources (all fetched 2026-10-08)
Twilio
- https://www.twilio.com/docs/usage/requests-to-twilio (and .md)
- https://www.twilio.com/docs/voice/api/call-resource (and .md, full pages incl. Update/Delete sections)
- https://www.twilio.com/docs/voice/tutorials/how-to-modify-calls-in-progress (and .md)
- https://www.twilio.com/docs/voice/twiml (request parameters), /twiml/play, /twiml/dial, /twiml/number, /twiml/conference, /twiml/refer, /twiml/redirect, /twiml/connect, /twiml/stream, /twiml/say (+ /say/text-speech for Turkish voices and limits)
- https://www.twilio.com/docs/voice/media-streams (DTMF statements, four-track limit, stopping a bidirectional stream), https://www.twilio.com/docs/voice/media-streams/websocket-messages
- https://www.twilio.com/docs/voice/api/conference-resource, /conference-participant-resource
- https://www.twilio.com/docs/taskrouter/contact-center-blueprint/call-control-concepts (cold/warm transfer sequences)
- https://www.twilio.com/en-us/blog/developers/tutorials/product/warm-transfer-openai-realtime-programmable-sip (2025-09-08)
- https://www.twilio.com/docs/voice/answering-machine-detection, /answering-machine-detection-faq-best-practices
- https://www.twilio.com/en-us/blog/answering-machine-detection-generally-available (2019-07-17), https://www.twilio.com/en-us/changelog/answering-machine-detection-is-now-available-for-dial-and-post-to-participants-calls (2022-10-07)
- https://www.twilio.com/en-us/voice/pricing/us (AMD, Media Streams, conference participant lines)
- Web search summaries (not primary text): AMD supported countries (2017 beta US/Canada), SIP REFER billing, update-during-stream behaviour (confirmed undocumented). Telnyx
- https://developers.telnyx.com/api-reference/call-commands/send-dtmf (.md), /transfer-call, /bridge-calls, /sip-refer-a-call, /streaming-start, /streaming-stop, /hangup-call, /dial (full page read up to ~100k chars)
- https://developers.telnyx.com/api-reference/conference-commands/create-conference, /join-a-conference, /hold-conference-participants
- https://developers.telnyx.com/docs/voice/programmable-voice/sending-commands, /voice-api-commands-and-resources, /voice-api-webhooks, /answering-machine-detection, /media-streaming
- https://developers.telnyx.com/api-reference/websockets/stream-call-media-over-websocket (stream frames, dtmf)
- https://developers.telnyx.com/docs/voice/sip-trunking/features/external-transfers, https://developers.telnyx.com/docs/inference/ai-assistants/warm-transfer-acceptance
- https://github.com/team-telnyx/telnyx-python (raw
src/telnyx/types/call_dtmf_received.py,call_machine_detection_ended.py,call_machine_greeting_ended.py; official SDK models used for webhook field names because the callback doc pages render no schema) - https://telnyx.com/pricing/voice-api
- crates.io API: reqwest 0.13.5, dtmf 0.1.5, dtmf_tones 1.0.2, dtmf_table 1.1.4, twiml-rust 0.1.0, search
dtmf; https://github.com/jmg049/dtmf_tones - Not fetched: Telnyx Webhook Fundamentals (signature verification steps),
send_sip_info,speak,playback_start, Telnyx premium AMD callback pages (render no schema), Twilio Calls list filters (child-call lookup), Twilio SIP REFER billing, per-account rate limits for both providers.
Fact-check log
Fact-checked 2026-10-08: 135 claims checked, 20 corrected. Every cited Twilio and Telnyx page, the four Telnyx SDK model files and the crates.io/GitHub entries were re-fetched on 2026-10-08. Corrections made in place above:
- Twilio Create Call:
Twimlis the parameter ignored when bothTwimlandUrlare given (file previously saidUrlwas ignored). SendDigitsvsMachineDetection: page saysMachineDetection"will be ignored"; wording fixed.- Update Call description quote "Modifies an active call..." not verbatim on the page; replaced by the actual wording.
- Parent/child call quotes reworded to the actual page text ("entirely new Call instance", "is considered in-progress by Twilio", "you must use
Status=completed"). <Stream>TwiML page: the quoted "doesn't say what happens..." was not a page quote; "one Stream per Call" belongs to the media-streams overview page.CallStatusvalues:initiatedis not in the page's list.- Twilio AMD page has no example async payload; marked as constructed.
- "Participants / Dial Number / Dial Sip are async only" not stated on the AMD page; marked UNVERIFIED (changelog says "run in the background").
- Conference resource quote corrected ("Conference rooms are not directly created from the Programmable Voice API").
- Twilio blog (2025-09-08): only "no unit tests" is stated; the claims of no briefing/summary and fragile error handling removed.
- Participants announce URL verb list (
<Play>,<Say>,<Pause>,<Redirect>) not confirmed; marked UNVERIFIED. - Telnyx 2.1:
client_state/command_id/error codes are not on the sending-commands page (re-attributed to command pages); node-routing claim andsource/metaerror members marked UNVERIFIED; 60-secondcommand_idwindow added. - Telnyx "compatible with the Standard Webhooks specification" not found on the webhooks page; marked UNVERIFIED.
- Telnyx AMD "outbound call creation or inbound call transfer" wording not found; marked UNVERIFIED.
- crates.io
dtmfdownloads: 3108 corrected to 9820. call.dtmf.receivedverbatim quote replaced by a paraphrase of the webhooks page.- Telnyx refer
sip_notify_responsepayload field not on the refer page; marked UNVERIFIED. - Twilio FAQ threshold hints: 1800-2000/1400-1500 are for residential/mobile callees (business: SpeechThreshold 1800-3000); clarified.
- TaskRouter warm-transfer step 2 reworded (new TaskRouter task, then TwiML into conference); Twilio
MachineDetectionTimeoutrange note (Create page lists none) added. - Telnyx:
stopframe afterstreaming_stopordering marked UNVERIFIED; premium event-ordering claim marked UNVERIFIED; added conferencemax_participantsconflict,conference.recording.saved, join default,prompt_end_timeout_millisdefault conflict (5000 vs 30000).
Remaining UNVERIFIED items: Twilio REST rate limits/idempotency/error body; Update Call Twiml max length and error codes for ended calls; effect of REST update on an active bidirectional stream; whether <Connect action> fires on server close; AnsweredBy field name in the Url webhook; AsyncAmdStatusCallbackMethod GET support; MachineDetectionTimeout range (3-59 vs 3-60); AMD countries/Turkish support (both providers); SIP REFER and <Dial> billing; Twilio machine_start definition; whether DTMF tones are also in the media payload; Telnyx rate limits, call_control_id node routing, send_dtmf max digits and stream interplay (90045 scope), premium AMD payload schemas and default timeout, premium event ordering, sip_notify_response, Standard Webhooks compatibility, Webhook Fundamentals signature steps, call.dtmf.received outside Gather, $0.10 transfer invocation scope, Turkish voices for Telnyx speak; ITU DTMF frequency table except keys 5 and 8; carrier voicemail length limits.