Skip to content

VAPI-4041: send Twilio's action params from /bw/continue - #31

Merged
mramasubramanian-bw merged 4 commits into
mainfrom
VAPI-4041/continue-action-params
Oct 8, 2026
Merged

mramasubramanian-bw merged 4 commits into
mainfrom
VAPI-4041/continue-action-params

Conversation

@mramasubramanian-bw

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

Copy link
Copy Markdown
Contributor

Summary

  • /bw/continue now answers each verb's action with the params Twilio would send for Dial, Record, and Gather, so customer handlers branch the same way they did on Twilio.
  • Dial action reporting considers all of a Dial's legs, keyed per Dial, instead of whichever leg's disconnect arrives first. A multi-number Dial reports the answered leg, and a second Dial on the same call can't inherit an earlier Dial's leg.
  • An empty Gather result no longer requests the action (Twilio semantics); actionOnEmptyResult="true" requests it with Digits="".
  • AGENTS.md and the verb matrix notes describe the new behavior.

Motivation / Context

Customer action handlers for Dial, Record, and Gather expect Twilio's request params (DialCallStatus, RecordingSid, Digits, etc.). Bandwidth's callback events don't have the same shape or ordering guarantees, so /bw/continue has to translate them and, for Dial, join two separate events before it can answer faithfully. Tracked in VAPI-4041.

Alternatives Considered

  • For the leg-leak fix found in review: clearing a call's saved legs as soon as its Dial completes was rejected. A leg event can arrive after the wait ends, so it would land after the clear and still leak into the next Dial on the same call. Per-Dial keys (<doc>.<index>) scope every leg to the Dial it belongs to, regardless of timing.

Reviewer Notes

  • DialCallSid/DialCallDuration come from the dialed leg's transferDisconnect, received at the new /bw/transfer-leg. Bandwidth documents no ordering between that and the parent's transferComplete, so the action waits up to transferLegWaitMs (default 400 ms). The wait ends early once a leg that answered has reported, or all N legs the Dial rang have.
  • Leg selection: the leg with answerTime wins. On a live multi-number Dial, Bandwidth reported the losing leg as cause: "hangup" with no answerTime (not cancel), so a hangup cause maps to completed only for an answered leg; an unanswered one is canceled. If none answered, all expected legs haven't reported yet, and the parent's cause is hangup (the transfer bridged, but the answered leg is still on its way), no leg is reported rather than a cancelled loser. Otherwise the last leg to report is used. A redelivered event for the same leg replaces the earlier one, so it can't count twice toward the expected legs.
  • Each translation mints a random 8-hex doc nonce; each Dial with an action gets the key <doc>.<index> on both its transferCompleteUrl (&dial=…&legs=N) and its targets' transferDisconnectUrl (?dial=…). URLs minted before this change carry no key and share one fallback bucket per call.
  • /bw/transfer-leg returns 400 for a malformed dial key rather than putting it in the fallback bucket.
  • On /bw/disconnect, a call's unread legs and its remembered TwiML document (up to MAX_TWIML_BYTES) are released, so neither accumulates. The call record itself stays, for the Calls API.
  • Gather resume re-translates the call's last-fetched TwiML (CallRecord.lastTwiml), held in memory only; see Breaking Changes for the fallback.
  • Record: Bandwidth's recordComplete has no equivalent of Twilio's Digits, so it is omitted.

Breaking Changes

  • An empty Gather (no digits, no speech) no longer requests the action. The verbs after the Gather run instead, matching Twilio. Handlers that relied on a Digits="" POST need actionOnEmptyResult="true" on the Gather.
  • Calls in progress at deploy time use URLs minted before this change; their empty Gathers fall back to requesting the action.
  • Gather resume depends on in-memory state. After a restart, or when the callback lands on a different replica, the action is requested as a fallback.
  • gatherUrl and transferCompleteUrl carry extra query params (&gather=N, &onEmpty=1, &dial=<doc>.<index>, &legs=N).
  • Translator module API: translateTwiml's transferLegUrl option is a function (dialIndex) => string, and the rewriteUrl callback receives a third RewriteContext argument. BXML-only callers are unaffected.

Tests

Added

Test Assertion
/bw/continue transferComplete -> Dial action › emits transferDisconnectUrl on the dialed number pointing at /bw/transfer-leg with creds Dial targets get a keyed transferDisconnectUrl with webhook creds, and the action URL names the same Dial and its leg count
… › joins the dialed leg's disconnect to the action: completed, bridged, DialCallSid, DialCallDuration The leg's transferDisconnect supplies the fields transferComplete lacks
… › reports busy / no-answer / canceled / failed from the leg's cause with DialBridged=false and duration 0 Bandwidth causes map to Twilio DialCallStatus values
… › waits briefly for a leg event that arrives just after the completion The join tolerates either arrival order within transferLegWaitMs
… › falls back to the parent's cause, without DialCallSid/DialCallDuration, when no leg event arrives Leg-only fields are omitted, not invented
… › a parent cause of hangup with no leg event is reported as completed and bridged Parent hangup alone implies a bridged, completed Dial
Dial action picks the right leg › reports the answered leg even when the cancelled leg reported first The answered leg wins regardless of arrival order
… › does not report a cancelled loser when the answered leg has not reported yet Parent bridged + answered leg missing → no leg reported
… › once every leg has reported, the legs win over a parent cause of hangup An unanswered single leg is reported even when the parent says hangup
… › a redelivered leg event counts once, so the wait still catches the answered leg A duplicate event doesn't end the wait before the answered leg arrives
… › a leg that ended with cause hangup but was never answered is canceled, not completed An unanswered leg with cause hangup (the live loser shape) maps to canceled, DialBridged=false
… › answers as soon as every leg of an unanswered multi-target Dial has reported The wait ends early once all N legs are in
… › a leg that reports after its Dial's wait is not applied to the next Dial Per-Dial keys stop a late leg leaking into the next Dial
… › drops unread legs when the call disconnects /bw/disconnect clears legs no wait consumed
/bw/transfer-leg › rejects a malformed dial key Bad dial param → 400
/bw/transfer-leg › requires webhook auth and safe ids Missing auth → 401; unsafe parentCallId → 400
/bw/continue recordComplete -> Record action › sends RecordingUrl, RecordingSid, RecordingDuration and registers the recording The recording is then fetchable through the Recordings facade
… › rejects an unsafe recordingId rather than registering it, and still continues the call A bad id isn't registered and doesn't break the call
/bw/continue gather with no input › resumes the document after the Gather instead of requesting the action (Twilio semantics) No input continues past the Gather; no customer POST
… › requests the action with Digits='' when actionOnEmptyResult="true" The opt-in flag requests the action
… › treats Bandwidth's speech-timeout sentinel text as no input "Speech timeout elapsed" text isn't counted as speech
… › still requests the action when digits or speech were received Non-empty results are unaffected
… › releases the remembered document when the call disconnects The stored TwiML is dropped on /bw/disconnect
… › falls back to requesting the action when there is no document to resume (fresh instance) No in-memory TwiML → action requested
… › a document fetched from a later action is what gets resumed Resume uses the most recently fetched document
Gather action context for the rewriter › passes a 1-based document index and the actionOnEmptyResult flag for each Gather The rewriter gets each Gather's position and flag
… › restarts the index for every document Gather numbering is per document
resumeAfterGather › emits only the verbs after the Nth Gather The translator can resume a document after a given Gather
… › keeps later Gathers at their original document index Resuming doesn't renumber later Gathers
… › resuming after the last Gather yields an empty Response, which ends the call as Twilio would Running off the end ends the call
… › resuming past a Gather that does not exist also yields an empty Response An out-of-range index fails safe
… › is a no-op when unset or zero Normal translation is unchanged
Dial transferDisconnectUrl › stamps the server's transfer-leg URL and callback creds on every target when a Dial has an action Every target of a Dial with an action gets the leg URL and creds
… › numbers each Dial with an action and tells the rewriter how many targets it rings The rewriter gets dialIndex/dialTargets; each Dial's targets get that Dial's leg URL
… › does not stamp it when the Dial has no action (nothing to report to) No action → no leg tracking
… › does not stamp it when no transferLegUrl is provided (standalone BXML generation) Standalone BXML generation is unchanged
… › describes the Dial action mapping and the caller-hangup case in a warning The Dial finding documents the action mapping

Modified

Test Assertion Why
POST /bw/initiate › rewrites Gather action through /bw/continue gatherUrl now ends in &gather=1 The Gather's document position rides along so an empty result can resume after it

Test Plan

  • npm run typecheck
  • npm test, including test/bxml-generator-band-conformance.test.ts with the band CLI installed
  • Local smoke run: the real translator server driven with hand-made Bandwidth webhooks against a stand-in Twilio customer app; every path correct, every Twilio signature valid, and band bxml raw accepted the BXML for multi-number Dial, repeated Dial, actionOnEmptyResult Gather, and speech Gather
  • Live call: single Dial answered reports completed, DialBridged=true, DialCallSid, DialCallDuration
  • Live call: single Dial not answered reports no-answer, DialBridged=false
  • Live call: declined Dial reports a busy-type DialCallStatus. Not testable with a mobile phone: carriers send a declined call to voicemail, which answers. Covered by unit tests.
  • Live call: multi-number Dial reports the answered leg, not the leg that ended first
  • Live call: a second Dial on the same call doesn't reuse the first Dial's leg
  • Live call: caller hangup during a transfer requests no action
  • Live call: Record action params, and the recording can be fetched
  • Live call: empty Gather falls through to the verbs after it
  • Live call: actionOnEmptyResult="true" posts Digits=""
  • Live call: silent speech Gather falls through

- Dial: map transferComplete to DialCallStatus/DialBridged, and join the
  dialed leg's transferDisconnect (new /bw/transfer-leg) for DialCallSid and
  DialCallDuration, waiting up to transferLegWaitMs (default 400 ms).
- Record: send RecordingUrl, RecordingSid, RecordingDuration from
  recordComplete and register the recording with the Recordings facade.
- Gather: on no digits or speech, resume the document after that Gather
  instead of requesting the action, unless actionOnEmptyResult="true".
@mramasubramanian-bw
mramasubramanian-bw requested review from a team as code owners October 1, 2026 17:50
@bwappsec

bwappsec commented Oct 1, 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.

Review fixes for the Dial action join:
- A multi-number Dial reported whichever leg disconnected first, usually a
  cancelled loser. Now all of the Dial's legs are considered and the one
  that answered wins; if the parent bridged but the winner has not
  reported, no leg is reported rather than a loser.
- Leftover or late leg events were applied to the next Dial on the call.
  Each Dial now gets a key (<doc nonce>.<index>) on both its
  transferCompleteUrl and its legs' transferDisconnectUrl, so legs only
  join their own Dial. The wait ends early once every expected leg has
  reported, and a call's unread legs are dropped on disconnect.
On a live multi-number Dial, Bandwidth reported the losing leg (cut off
when another number answered) as cause "hangup" with no answerTime, not
"cancel". Leg selection already keys on answerTime, but when such a leg
was the one reported, the action got DialCallStatus=completed with
DialBridged=false. A "hangup" cause now means completed only for an
answered leg; an unanswered one is canceled. The multi-number tests use
the observed event shape.
- Report the legs, not the parent's inference, once every expected leg
  has reported: a parent cause of "hangup" only means "the answered leg
  is still on its way" while legs are missing.
- A redelivered transferDisconnect for the same leg replaces the earlier
  one instead of counting twice toward the expected legs, which could end
  the wait before the answered leg arrived.
- Release a call's remembered TwiML (up to MAX_TWIML_BYTES) on disconnect.
- Use secondsBetween for the disconnect and Calls GET durations and one
  helper for recording durations; drop event and leg fields nothing reads.
- Type translateVerb's rewrite as RewriteUrl so the Gather/Dial context
  can't be lost by a narrower signature.
@mramasubramanian-bw
mramasubramanian-bw merged commit e0f5727 into main Oct 8, 2026
6 checks passed
@mramasubramanian-bw
mramasubramanian-bw deleted the VAPI-4041/continue-action-params branch October 8, 2026 13:34
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.

4 participants