Skip to content

fix(workflows): forward Twilio CallToken during warm transfers - #2510

Open
piyush-gambhir wants to merge 5 commits into
livekit:mainfrom
piyush-gambhir:feat/twilio-call-token
Open

piyush-gambhir wants to merge 5 commits into
livekit:mainfrom
piyush-gambhir:feat/twilio-call-token

Conversation

@piyush-gambhir

@piyush-gambhir piyush-gambhir commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

A warm transfer creates a new outbound call, so human recipients normally see the business number rather than the inbound customer's number. This adds per-call caller-ID preservation through Twilio's CallToken, while keeping the business number as the default and fallback. It lets applications use the SDK's consultation and cleanup flow instead of replacing Twilio call creation to preserve customer identity.

API and behavior

  • twilioFromNumber stays the agent/business Twilio number or verified caller ID.
  • Optional originalCallerNumber carries the original inbound webhook's From.
  • A nonempty twilioCallToken opts into preservation and requires the matching original caller number. No separate enable flag or fallback-number option is needed.
  • Without a token (including an empty token), dial from the business number even if the original caller number is supplied.
  • If the preservation request is explicitly rejected with HTTP 400 / Twilio error 21210, retry once from the business number without CallToken. This error means the original caller ID was not accepted; it was also the response observed for the invalid-token live control.
  • Do not retry transport timeouts, unrelated rejection codes, authentication/rate-limit errors, or failures after a call was created. A failed fallback propagates normally. This avoids duplicate dialing after an ambiguous outcome.

Keep the number/token pair in server-side state keyed by the incoming CallSid, obtained from a validated Twilio webhook. Tokens are not included in connector requests, TwiML, prompts, or participant attributes. The token is not permission to choose arbitrary caller IDs.

This can help recipients identify the customer and support caller-ID-based lookup/callback records; the PR does not implement CRM matching. Caller-ID presentation remains subject to Twilio acceptance and downstream carrier behavior. The existing Calls API is used, with no conference or LiveKit server API change.

Twilio references

Prerequisite

This PR targets main and depends on #2402, which introduces the JavaScript connector-transfer helper. The branch includes its two prerequisite commits with original authorship and merges current main. Once #2402 merges, the branch can be rebased to leave only this feature. The public API report and examples are updated.

Python counterpart: livekit/agents#7309.

Validation

  • 40 focused workflow tests passed, covering token forwarding/omission, original-caller validation, single fallback, failed fallback, unrelated errors, network ambiguity, and cancellation of the fallback call.
  • Typecheck, SDK build, API Extractor check, formatting, and repository lint passed (existing lint warnings remain).
  • Error responses expose status/code without logging provider response bodies that could contain caller data or tokens.

Live QA — 2026-09-16, current fallback behavior

Ran both local SDK workers against UAT LiveKit Cloud and real Twilio calls. Two temporary phone numbers were used: a customer/receiver in a temporary subaccount and a business number in the SDK account. This makes the customer's number unverified in the dialing account. Each SDK passed all four cases below (8/8 total):

Input Observed result
Valid token + original caller Transfer completed; receiver saw the customer caller ID.
No token, no original caller Transfer completed; receiver saw the business caller ID.
Original caller supplied, no token Transfer completed; receiver saw the business caller ID.
Invalid token + original caller Preservation rejected; fallback completed with the business caller ID.

Every successful case had one receiver webhook and published audio tracks for the customer and transferred human in the caller room. The invalid-token Python attempt also recorded the SDK's explicit caller-ID fallback warning. Unit tests verify both request bodies, the exact single-retry limit, and failure exclusions.

One Python harness attempt raced room initialization before dialing. After adding an explicit room connection in the harness, that case passed using the same temporary numbers; interrupted harness attempts are excluded from the final matrix. No SDK behavior was changed to work around that harness issue.

Independent API reads confirmed both purchased numbers released (404), the temporary subaccount closed, 38 associated call legs checked with zero active, and zero QA rooms remaining. Local QA workers and the webhook tunnel were stopped.

Earlier live QA on the pre-fallback revision also covered supervisor decline, no answer, busy/rejected calls, and caller hangup during ringing in both SDKs. Those lifecycle cases are historical validation, not a fresh full-matrix run of this revision. Current regression tests cover cancellation of an unanswered fallback call.

Limits: automated endpoints verify caller-ID signaling and published audio tracks, not physical handset display, subjective two-way audio quality, or every carrier. Telnyx is not included in these results.

@changeset-bot

changeset-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c54ace2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Minor
@livekit/agents-plugin-anam Minor
@livekit/agents-plugin-anthropic Minor
@livekit/agents-plugin-assemblyai Minor
@livekit/agents-plugin-azure Minor
@livekit/agents-plugin-baseten Minor
@livekit/agents-plugin-bey Minor
@livekit/agents-plugin-cartesia Minor
@livekit/agents-plugin-cerebras Minor
@livekit/agents-plugin-deepgram Minor
@livekit/agents-plugin-did Minor
@livekit/agents-plugin-elevenlabs Minor
@livekit/agents-plugin-fishaudio Minor
@livekit/agents-plugin-google Minor
@livekit/agents-plugin-hume Minor
@livekit/agents-plugin-inworld Minor
@livekit/agents-plugin-krisp Minor
@livekit/agents-plugin-lemonslice Minor
@livekit/agents-plugin-liveavatar Minor
@livekit/agents-plugin-livekit Minor
@livekit/agents-plugin-meta Minor
@livekit/agents-plugin-minimax Minor
@livekit/agents-plugin-mistral Minor
@livekit/agents-plugin-mistralai Minor
@livekit/agents-plugin-neuphonic Minor
@livekit/agents-plugin-openai Minor
@livekit/agents-plugin-perplexity Minor
@livekit/agents-plugin-phonic Minor
@livekit/agents-plugin-protoface Minor
@livekit/agents-plugin-resemble Minor
@livekit/agents-plugin-rime Minor
@livekit/agents-plugin-runway Minor
@livekit/agents-plugin-sarvam Minor
@livekit/agents-plugin-silero Minor
@livekit/agents-plugin-soniox Minor
@livekit/agents-plugin-tavus Minor
@livekit/agents-plugins-test Minor
@livekit/agents-plugin-trugen Minor
@livekit/agents-plugin-xai Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread agents/src/workflows/warm_transfer.ts Outdated
Comment on lines +931 to +936
const callSid = await createTwilioCall(auth, {
to: phoneNumber,
from: twilioFromNumber,
twiml,
callToken: twilioCallToken,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Cancelled transfers still place calls

When cancellation occurs during connectTwilioCall, createTwilioCall still runs without checking signal. The human agent can receive a call after the transfer ended.

Learn more

The outer dial race stops waiting when its abort controller fires, but it cannot stop the already-running origination hook. That hook resumes after connectTwilioCall settles and unconditionally creates the Twilio call. Its later answer wait notices the aborted signal and requests cancellation, but the unwanted call has already been placed and can ring or answer first.

Example: The caller hangs up while connectTwilioCall takes two seconds. The warm-transfer task completes and deletes the briefing room. When the connector request returns, the hook still calls Twilio, so the supervisor's phone rings for a cancelled transfer.

Recommended fix: Check signal.aborted before each side effect, especially before createTwilioCall. Also pass signal into the underlying connector and Twilio requests where their APIs support cancellation, while retaining post-creation cancellation for races after Twilio returns a call SID.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1055 to +1056
async function cancelTwilioCall(auth: TwilioRestAuth, callSid: string): Promise<void> {
await twilioRequest(auth, `/Calls/${encodeURIComponent(callSid)}.json`, { Status: 'canceled' });

@devin-ai-integration devin-ai-integration Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Rejected cancellations leave calls ringing

When Twilio rejects Status=canceled, cancelTwilioCall still resolves because fetch accepts HTTP errors. The abandoned outbound call can continue ringing.

Learn more

A Fetch promise resolves normally for 4xx and 5xx responses. Therefore the current cancellation helper reports success for every HTTP response, and the surrounding .catch() only handles transport failures. The briefing room is then torn down while Twilio still owns a live ringing call.

Example: The answer timeout expires and Twilio returns HTTP 500 for the cancellation request. cancelTwilioCall resolves, the transfer reports failure, and the supervisor's phone keeps ringing until Twilio ends the call independently.

Recommended fix: Make cancelTwilioCall inspect resp.ok and throw a redacted error for non-success responses. Handle that rejection explicitly at the call site with an appropriate log and, if cancellation reliability requires it, a bounded retry policy.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const callSid = form.get('CallSid') ?? '';
const caller = form.get('From') ?? '';
console.log(`Inbound call ${callSid} from ${mask(caller)}`);

@devin-ai-integration devin-ai-integration Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Twilio call identifiers bypass redaction

Inbound CallSid values enter multiple log messages directly. Message text cannot be redacted, exposing provider call identifiers in logs.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

/** Route one entry of the webhook's `calls` array to the connector API. */
async function handleCallEvent(call: WhatsAppCall, phoneNumberId: string): Promise<void> {
const { id: callId, event, direction } = call;
console.log(`Call event ${event} (${direction}) for ${callId}`);

@devin-ai-integration devin-ai-integration Bot Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 WhatsApp payloads bypass log redaction

Webhook call IDs and event fields enter log messages directly. These provider values bypass the required structured PII redaction path.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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