Skip to content

VAPI-4042: emit Twilio's Gather/Record defaults and clamp to BW's ranges - #32

Open
mramasubramanian-bw wants to merge 5 commits into
mainfrom
VAPI-4042/gather-record-defaults
Open

mramasubramanian-bw wants to merge 5 commits into
mainfrom
VAPI-4042/gather-record-defaults

Conversation

@mramasubramanian-bw

@mramasubramanian-bw mramasubramanian-bw commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • <Gather>/<Record> now use Twilio's own default values instead of letting Bandwidth's different defaults silently change IVR behavior: finishOnKey, timeout → firstDigitTimeout/interDigitTimeout, Record's maxLength, and Record's timeout → silenceTimeout (this wasn't translated to anything before).
  • numDigits/timeout/maxLength are now kept within the limits Bandwidth documents, with a warning, instead of letting an out-of-range value through for Bandwidth to reject. A value that isn't a plain number at all (empty or all spaces, "abc") now warns and falls back to Twilio's own default, instead of being copied into the BXML as written. A number is always written out in plain decimal form, never scientific notation or a hex/scientific-notation string. numDigits is additionally rounded to a whole number, since there's no such thing as collecting half a digit — timeout/maxLength keep a fractional value as written, since a fractional duration is a real, normal concept.
  • finishOnKey is now checked on both verbs. Gather keeps only the characters Bandwidth actually recognizes as a key (a digit, *, or #) and drops anything else, so a value like "ab" can't silently turn the stop-key feature off. Record is cut down to the one real keypad key it can use to stop — preferring #, then *, then the first valid one present — since Bandwidth can't use a whole set of keys the way Twilio does; a value with no valid key at all turns the stop-key feature off, rather than quietly adopting Bandwidth's own default key. A blank (whitespace-only) finishOnKey is treated the same as leaving it out, not as a value to filter or disable.
  • A <Gather>/<Record> with no action now sends its result back to the same page that served it, matching Twilio, when the server knows that page's address. If that page can't be reached because a different server has no record of it, the call now ends instead of replaying the same prompt — see the Reviewer Notes for a real limitation of this fallback.
  • playBeep/trim now warn when they're left out too (Twilio's default plays a beep and trims silence), not only when someone sets them to a different value on purpose.

Motivation / Context

translateGather/translateRecord only copied an attribute when the TwiML set it. Where Twilio's default is different from Bandwidth's, the translated verb behaved differently with no warning: a default <Record> cut voicemail off at 60 seconds with no way to stop on silence, a default <Gather> counted # as just another digit and could cut an entry short mid-way, and a value outside Bandwidth's limits made Bandwidth reject the whole call. Tracked in VAPI-4042.

Reviewer Notes

  • A real, known limitation, not something this PR can fully close: a <Gather> with no action points its gatherUrl back at the page that served it. If the caller presses nothing and that page is reached on a server that has no cached copy of the document (a restart, or — in a multi-replica deployment with no session affinity routing a call's requests back to the same server — a different replica), there is no way to know what verb should run next, so the call now ends instead of looping (a second round of review caught that it used to loop forever by re-fetching and replaying that same page). Ending the call is still a real behavior difference from Twilio, which would simply continue. Fixing this properly needs a document cache shared across replicas instead of each process's own memory, which is a larger change than this PR; flagged in AGENTS.md for anyone whose deployment this affects.
  • "Send the result back to the same page" is driven by a new TranslateOptions.documentUrl, set only by the server (one line in app.ts, next to the existing rewriteUrl). The standalone BXML generator (src/bxml-generator) leaves it unset and keeps the original warning, since it has no live server to send a request back to — doing this there would just produce a useless empty gatherUrl="" in a static file.
  • Gather's timeout sets both firstDigitTimeout and interDigitTimeout, each kept within its own limits (0-60 and 1-60). One Twilio value can land differently for each — e.g. timeout="0" leaves firstDigitTimeout="0" alone but raises interDigitTimeout to 1 — so these can be two separate warnings when the value is out of range. A value that isn't a number at all (e.g. "abc") is checked once, not once per BXML attribute, so that case produces a single warning, not two near-identical ones.
  • Record's finishOnKey: Bandwidth's docs say "this digit" (singular), so a Twilio value with more than one distinct valid character (including the implicit default, any of 1234567890*#) can only be reduced to one character — and only to a character Bandwidth actually recognizes as a key. A single such character someone set on purpose passes through with no warning, since that's an exact match; a value made of nothing but that one character repeated (e.g. "##") is also left alone with no warning, since nothing is actually being narrowed away. A value with none of those characters at all (e.g. a letter) can't be reduced to anything; since that value would never match a real key press on Twilio either, the stop-key feature is turned off rather than quietly adopting Bandwidth's own default key, which would give the customer a way to stop the recording they never wrote into their TwiML.
  • playBeep/trim warn whenever Twilio's own behavior (play a beep / trim silence) wouldn't happen on Bandwidth — including when they're left out, since Twilio's defaults for both are "yes, do it" (playBeep defaults to true, trim defaults to trim-silence). The one setting that doesn't warn for each (playBeep="false", trim="do-not-trim") is the one case where Bandwidth's fixed behavior (never beeps, never trims) actually matches.
  • Record timeout="0" (Twilio: turn off silence detection) and Bandwidth's own silenceTimeout="0" (no timeout) already mean the same thing, so that value needed no special handling.
  • timeout/maxLength/numDigits/finishOnKey set to an empty or all-spaces value are all treated the same as leaving the attribute out — not as the number zero (Number(" ") is 0 in JavaScript), and not, for finishOnKey, as a value to filter down to nothing — since a blank value isn't something a customer would set on purpose, there's no warning either, just the same default used for the missing case. An exactly empty finishOnKey="" is handled differently from a blank (whitespace) one, since both Twilio and Bandwidth document that specific value as "turn the stop-key feature off" on purpose.
  • Warnings about an out-of-range or invalid value name the attribute the customer actually wrote (timeout, maxLength), not only Bandwidth's name for it (firstDigitTimeout, maxDuration), while still saying which Bandwidth attribute is affected — so the warning can be found by searching the customer's own TwiML.
  • Checked numDigits="2.5" directly against band bxml raw (Bandwidth's own BXML validator): it's accepted, for both Gather's and Record's attributes. Bandwidth's own docs list Gather's ranges as "decimal values" (silent on Record's), so a fractional duration isn't a bug — timeout/maxLength pass a fraction through as a plain decimal. numDigits is the one exception: a fractional digit count has no real meaning regardless of whether Bandwidth's parser happens to accept the string, so that one is rounded.
  • Identical findings (same severity, verb, and message) are now reported once per document rather than once per verb — a document with many ordinary bare <Record>s previously repeated the same 3 generic warnings for every one of them, which could bury a real out-of-range warning under dozens of repeats of an unrelated one. The compatibility-check tool had its own separate copy of this same logic (used when one file has several TwiML documents embedded in it), which disagreed slightly on what counts as a duplicate; both now share one implementation.
  • The duplicated "no action → send the result back to the same page, or warn if that's not possible" logic in translateGather and translateRecord is now one shared helper.
  • Also left as a deliberate follow-up, now flagged across three separate reviews: the translator keeps several pieces of per-translation state (including the new documentUrl from this PR) as module-level variables reset at the start of each translation, rather than in one object passed through every verb-translating function. The real fix is a genuine improvement, but it touches the signature of every verb-translation function and its recursive call sites — a larger, separate refactor than felt safe to do right before merging a PR that's already been through three rounds of correctness fixes. Left as a code comment at the declaration site so it isn't re-discovered a fourth time.

Breaking Changes

  • A bare <Gather> (no finishOnKey) now stops on #, matching Twilio; before, # was just another digit (Bandwidth's own default turns off the stop-key feature). Any flow that expected # to be collected along with the other digits will now stop there instead.
  • A bare <Record> (no timeout) now stops after 5 seconds of silence (silenceTimeout), which had no equivalent at all before — a long pause mid-recording can now end it sooner than it used to.
  • A <Gather>/<Record> with no action attribute now sends a real request back to the customer's current page (carrying Digits/RecordingUrl, etc.), instead of Bandwidth quietly dropping the result (Gather) or moving on to the next verb once the recording ends (Record). This matches Twilio, but it's a request shape that page may not have expected before. In a multi-replica deployment with no session affinity, a silent caller on a bare <Gather> can have the call end instead of continuing — see the Reviewer Notes.
  • numDigits/timeout/maxLength outside Bandwidth's documented range, or not a plain number at all, are now brought back into range (with a warning) instead of passed through as written, which used to risk Bandwidth rejecting the call outright.
  • A bare <Gather> with no timeout set does not change behavior: Bandwidth already defaults firstDigitTimeout/interDigitTimeout to 5 seconds on its own, matching Twilio (re-confirmed directly against Bandwidth's live docs). What does change: an explicit timeout previously only reached firstDigitTimeout, silently leaving interDigitTimeout at Bandwidth's 5-second default regardless of what was set — now it governs both, matching Twilio's single timeout value doing both jobs. (An earlier version of this PR description got this backwards; a reviewer caught it.)

Tests

Added

Test Assertion
Gather > Twilio defaults › emits finishOnKey's Twilio default (#) when omitted terminatingDigits="#" when finishOnKey isn't set
… › passes finishOnKey="" through unchanged Twilio's and Bandwidth's "off" value is left as-is
… › drops a character Bandwidth doesn't recognize as a key, and warns finishOnKey="ab" doesn't go out unchanged
… › keeps the valid characters in a mixed set and drops only the invalid one finishOnKey="1a#" becomes "1#"
… › treats a whitespace-only finishOnKey the same as omitted, not as an invalid value to filter A space isn't a deliberate value
… › emits timeout's Twilio default (5) as both firstDigitTimeout and interDigitTimeout A left-out timeout still sets both attributes
… › does not emit maxDigits when numDigits is omitted Bandwidth's own default (50) is left alone
Gather > keeping values within Bandwidth's limits › brings an out-of-range timeout back within limits for both firstDigitTimeout and interDigitTimeout, with a warning for each One Twilio value produces two separate warnings
… › enforces interDigitTimeout's minimum (1) separately from firstDigitTimeout's minimum (0) timeout="0" only raises one of the two attributes
… › limits numDigits to Bandwidth's 50-digit maximum An out-of-range numDigits is brought into range with a warning
… › leaves an in-range value untouched with no warning No warning fires for ordinary values
… › treats timeout="" the same as omitted, not as 0 Number("") is 0; an empty value must not be read as a real zero
… › treats a whitespace-only timeout the same as empty, not as 0 Same, for " "
… › treats a whitespace-only numDigits the same as omitted Same, for numDigits=" "
… › normalizes a non-decimal but in-range value instead of passing it through as written timeout="0x3C" becomes "60", not left as the hex string
… › normalizes scientific notation to a plain decimal numDigits="1e1" becomes "10"
… › falls back to Bandwidth's own default and warns when the value isn't a number at all numDigits="abc" becomes "50" with a warning, not passed through as "abc"
… › never emits scientific notation, even for a very small value timeout="0.0000001" doesn't come out as "1e-7"
… › rounds numDigits to a whole number and warns numDigits="2.5" becomes "3", since you can't collect half a digit
… › warns only once for a non-numeric timeout, not once per BXML attribute it maps to timeout="abc" produces exactly one warning, not two
Gather > no action, re-requesting the current document › points gatherUrl at the current document when the server provides one, with no warning documentUrl set → gatherUrl points back at the same page; the Gather's position, empty-result flag, and self-reference marker all travel through
… › still warns when no documentUrl is given Standalone generation keeps the original warning
Record > Twilio defaults › emits maxLength's Twilio default (3600) when omitted maxDuration="3600", not Bandwidth's 60-second default
… › emits timeout as silenceTimeout, defaulting to Twilio's 5 seconds silenceTimeout is translated to something at all now
… › passes timeout="0" through unchanged Twilio's "off" value matches Bandwidth's
Record > keeping values within Bandwidth's limits › limits maxLength to Bandwidth's 10800s (3h) maximum Warning names the original value
… › limits timeout (silenceTimeout) to Bandwidth's 10800s maximum Same, for silenceTimeout
… › treats maxLength="" the same as omitted, not as 0 An empty maxLength must not cut recordings to 1 second
… › treats timeout="" the same as omitted, not as 0 Same, for Record's timeout
… › treats a whitespace-only maxLength the same as empty, not as 0 Same bug, a space instead of nothing
… › treats a whitespace-only timeout the same as empty, not as 0 Same, for Record's timeout
… › keeps a fractional maxLength as-is, unlike numDigits maxLength="90.5" stays "90.5", no warning — a fractional recording length is a real concept
Record > finishOnKey -> a single terminatingDigit › warns when omitted Bandwidth's own # default only covers one of Twilio's "any key" set
… › passes a single explicit digit through with no warning Exact match, nothing to reduce
… › prefers # when a multi-character set includes it, and warns # is picked over the other characters in the set
… › falls back to the first character when # is not in a multi-character set, and warns Always picks the same character when # isn't an option
… › picks the first valid character in a mixed set rather than blindly the first character finishOnKey="ab1" picks "1", not "a"
… › disables the stop key and warns when there is no valid key at all, rather than falling back to Bandwidth's own default finishOnKey="a" turns the feature off instead of adopting Bandwidth's "#"
… › does not warn when a repeated single valid character narrows to nothing different finishOnKey="##" doesn't warn — nothing was actually narrowed
… › treats a whitespace-only finishOnKey the same as omitted, not as an invalid value to disable A space gets Bandwidth's default "#", with the same warning as the omitted case
Record > playBeep and trim › warns when playBeep is omitted and when explicitly true Twilio's default (a beep plays) isn't matched
… › does not warn about playBeep when explicitly false The one setting Bandwidth's behavior matches
… › warns when trim is omitted and when explicitly trim-silence Twilio's default (trims silence) isn't matched
… › does not warn about trim when explicitly do-not-trim The one setting Bandwidth's behavior matches
Record > no action, re-requesting the current document › points recordCompleteUrl at the current document when the server provides one recordCompleteUrl points back at the same page, no "missing action" warning
… › warns when no documentUrl is given Standalone generation keeps the original warning
repeated identical findings collapse to one › a document with many bare Records reports each generic warning once, not once per Record 10 bare <Record>s produce one playBeep warning, not ten
… › does not collapse findings that differ, e.g. out-of-range warnings naming different values Two <Record>s with different out-of-range maxLengths each keep their own warning
analyzeSource › collapses the same finding repeated across several <Response> blocks in one file The shared dedup rule also covers a file with several embedded TwiML documents
Gather/Record with no action re-request the current document, like Twilio › Gather posts Digits back to the same voice URL that served it Through the real server end to end: digits go back to the original voice page
… › Record posts RecordingUrl back to the same voice URL that served it Same, for a finished recording
… › resumes past the Gather on empty input when the same instance has the document, even with no action A self-pointing Gather still resumes normally when the document is cached
… › ends the call, rather than replaying the same prompt forever, when a different instance has no document to resume The replay-loop fix: two separate server instances, no shared memory
generate → band BXML conformance › band accepts generated BXML for gather-record-limits.xml A new fixture covering every limit and the finishOnKey=""/multi-character cases, checked against Bandwidth's own CLI

Modified

Test Assertion Why
Gather › maps attributes and nested prompts Also checks interDigitTimeout="7" timeout now sets both attributes, not just firstDigitTimeout

Test Plan

  • npm run typecheck
  • npm test, including test/bxml-generator-band-conformance.test.ts with the band CLI installed
  • Ran the real translator server with hand-made Bandwidth requests (no phone): bare Gather/Record defaults, extreme/invalid out-of-range values (empty strings, whitespace, non-numeric, hex, scientific notation, very small decimals, fractional digit counts), an invalid/blank/repeated finishOnKey on both verbs, the no-action case sending results back to the original voice page, and the replay-loop scenario reproduced across two separate server instances with no shared memory; band bxml raw accepted every document, including every new edge case from this round
  • Confirmed each fix in this PR against a reproduction that failed without it
  • Live call: a bare Gather with no finishOnKey stops on #, and the # is not included in Digits
  • Live call: a bare Record with no timeout stops on silence (silenceTimeout), not at the maxLength safety cap
  • Live call: a bare Gather/Record with no action sends its result back to the page that served it, with the caller's digits/recording

Twilio and Bandwidth default differently for several Gather/Record
attributes; leaving them unset let Bandwidth's own default silently
change IVR behavior instead of matching Twilio's.

Gather:
- finishOnKey omitted -> emit "#" (Twilio's default); Bandwidth's own
  default is "" (disabled). finishOnKey="" still passes through.
- timeout omitted -> default "5", set as both firstDigitTimeout and
  interDigitTimeout (Bandwidth splits what Twilio combines).
- numDigits/timeout clamped to Bandwidth's documented ranges with a
  warning naming the original value.
- A Gather with no action re-requests the current document, matching
  Twilio, when the server provides the document's URL.

Record:
- maxLength omitted -> emit "3600" (Twilio's default); Bandwidth's own
  default is 60s.
- timeout (silence ends the recording) is now mapped at all, to
  silenceTimeout; previously dropped entirely. timeout="0" (Twilio:
  disable) and silenceTimeout="0" (Bandwidth: no timeout) agree.
- finishOnKey narrowed to Bandwidth's single terminating digit
  (preferring #, then *, then the first character), with a warning,
  since Bandwidth can't represent Twilio's multi-key set (default: any
  key). A single explicit digit passes through unchanged.
- playBeep/trim now warn on the omitted (Twilio-default) case too, not
  only when explicitly set to a non-matching value.
- A Record with no action re-requests the current document, matching
  Twilio, when the server provides the document's URL.

The "re-request current document" behavior is driven by a new
TranslateOptions.documentUrl, set only by the server (one line in
app.ts); standalone BXML generation leaves it unset and keeps the
original warning, since there's no live document for it to re-fetch.
@mramasubramanian-bw
mramasubramanian-bw requested review from a team as code owners October 13, 2026 15:14
@bwappsec

bwappsec commented Oct 13, 2026 •

Copy link
Copy Markdown

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues
✅ Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

The warning a customer sees when a value is outside Bandwidth's range
said "clamped to X" instead of plainly saying what happened. It now
says "using X instead". The matrix notes, AGENTS.md, and the new test
names follow the same change ("kept within Bandwidth's limits" /
"limits X to Bandwidth's maximum" rather than "clamped").

The clampAttr function name and its internal comments are unchanged;
"clamp" was already an internal term in this file (the Say/Play loop
count logic), so this only touches text a person actually reads.
- "" on timeout/maxLength was read as 0 (Number("") === 0), not as
  missing: <Record maxLength=""/> cut voicemail to 1 second, and
  <Gather timeout=""/> sent an empty firstDigitTimeout. Treated as
  missing now (clampAttr's callers use || instead of ?? for these).
- A value that isn't a plain finite number ("abc", "Infinity") now
  warns and falls back to Twilio's default, instead of being copied
  into the BXML as-is for Bandwidth to reject. A value that IS a
  number but an unusual form ("0x3C", "1e1") is normalized to a plain
  decimal instead of being emitted as written.
- Record's finishOnKey narrowing now only picks a real keypad
  character: "ab1" narrows to "1", not "a", and a value with no digit,
  "*", or "#" at all (e.g. "a") is dropped instead of passed through,
  so Bandwidth's own default applies.
- Range warnings now name the Twilio attribute the customer actually
  wrote (timeout, maxLength) rather than only Bandwidth's name for it
  (firstDigitTimeout, maxDuration), while still saying which BXML
  attribute is affected.
- Identical findings (verb + message) collapse to one per document, so
  a document with many ordinary bare Records doesn't bury a real
  out-of-range warning under repeats of the same generic one.
- Corrected AGENTS.md: Bandwidth already defaults firstDigitTimeout and
  interDigitTimeout to 5s on its own, so an omitted Gather timeout
  isn't a behavior change; the real fix is that an explicit timeout
  previously only reached firstDigitTimeout. Also listed maxLength
  among the range-limited attributes and stopped calling finishOnKey's
  narrowing a range limit.
- Pulled the duplicated "no action -> re-request the current document,
  else warn" branching out of translateGather/translateRecord into one
  resolveActionUrl helper.
…findings

- Most serious: a Gather with no action that reaches a server instance
  with no cached copy of its document (restart, a different replica)
  now ends the call instead of re-fetching and replaying the exact
  same prompt. Before this fix, the same conditions would recur every
  time, looping forever. A new RewriteContext.selfReference flag
  (carried as &self=1 on the URL) lets /bw/continue tell "this points
  at a real customer action" apart from "this points at myself".
- A whitespace-only value (e.g. " ") hit the same Number("")===0 bug
  as an empty one; both are now treated as not set.
- Gather's finishOnKey had no validation at all: "ab" (no real keypad
  key) went out unchanged. It's now filtered to the characters
  Bandwidth recognizes (0-9, *, #), same as Record already was.
- An invalid Record finishOnKey (no real key at all) now disables the
  stop key ("") instead of falling back to Bandwidth's own default
  ("#"), which would have given a customer's TwiML a stop key they
  never asked for.
- translate.ts's dedupeFindings and compatibility-check/analyze.ts's
  own separate dedup logic disagreed on what counts as a duplicate
  (one included severity, the other didn't). Now there's one shared,
  exported dedupeFindings both use.
- Checked numDigits="2.5" against `band bxml raw` directly: Bandwidth
  accepts decimal values for these attributes (its own docs say so),
  so this is not a bug.
- Re-confirmed against Bandwidth's live docs that firstDigitTimeout
  and interDigitTimeout both already default to 5s on Bandwidth's
  side, same as previously corrected.
- Left the module-level translation state (now six variables reset per
  call, including the new one from this PR) as a follow-up: threading
  a context object through every verb-translation function instead
  is a much larger, separate change from this PR's scope.
- A very small value (e.g. timeout="0.0000001") came out as
  firstDigitTimeout="1e-7" -- String(n) switches to scientific notation
  below 1e-6, contradicting this code's own claim that it never would.
  Fixed with a dedicated plain-decimal formatter.
- numDigits is now rounded to a whole number with a warning
  (numDigits="2.5" -> "3"): there's no such thing as collecting half a
  digit. timeout/maxLength keep a fractional value as written -- a
  fractional duration is a real concept, and Bandwidth's own docs
  allow decimal values there (confirmed: band bxml raw accepts them).
- A non-numeric Gather timeout (e.g. "abc") produced two
  near-identical warnings, one per BXML attribute it maps to. Numeric
  validation now happens once per Twilio attribute, range-checking
  happens separately, so it warns once.
- Gather's finishOnKey="##" warned about "narrowing" even though "#"
  is kept exactly -- nothing was actually lost. Now only warns when
  there's a genuine choice between distinct keys or an invalid
  character is being dropped.
- A whitespace-only finishOnKey (e.g. " ") turned the stop key off,
  unlike every other blank attribute, which falls back to Twilio's
  default. Now treated the same way as omitted on both verbs.
- matrix.json and AGENTS.md still described Record's invalid-finishOnKey
  case as "dropped, falling back to Bandwidth's default (#)" -- stale
  from before the previous round changed that to disabling the stop
  key instead. Corrected, and extended both to cover the Gather
  character-filtering and single-warning behavior these docs were
  missing entirely.
- Documented two things explicitly rather than silently fixing them a
  fourth time: a bare Gather's no-document-to-resume fallback ends the
  call rather than loop, which is a real behavior gap from Twilio on a
  multi-replica deployment with no session affinity -- flagged in
  AGENTS.md as worth a shared document cache if it matters for a given
  deployment. And the module-level translation state, now flagged
  three times across three reviews, is left as a deliberate follow-up
  in a code comment explaining why: the real fix (a context object
  threaded through every verb function) is a larger, separate change
  than felt safe to rush right before merge.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants