Skip to content

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

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

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

Conversation

@piyush-gambhir

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

Copy link
Copy Markdown
Contributor

Description

Warm transfers normally show the business number to the supervisor. This adds optional per-call caller-ID preservation to TwilioConnectorWarmTransferTask through Twilio's CallToken, keeping the business number as the default and fallback.

Replaces #2510 with linear history. Includes the two dependency commits from #2402 by @anunaym14 with original authorship because the connector workflow was absent from the base. The contribution also has its own patch changeset for @livekit/agents.

Changes Made

  • twilioFromNumber remains the business/verified number. Supply originalCallerNumber and a nonempty twilioCallToken from the same validated inbound webhook to preserve the caller ID. Without a token, use the business number.
  • Retry once from the business number without the token only for HTTP 400 with Twilio error 21210 (unverified From) or 21212 (invalid From, including unavailable caller IDs). No fallback after cancellation, on transport failures/unrelated errors, or after a call was created; a failed fallback is not retried.
  • An abort during connector setup prevents the outbound call. Calls that finish creating after abort are canceled using their returned SID, including fallback calls.
  • HTTP and transport failures during cancellation produce a warning while preserving the original transfer failure. Logs omit response bodies and transport-error text to avoid disclosing request data.
  • The CallToken is sent only in the Calls API form, outside TwiML, connector requests, prompts, and participant attributes. Keep it in server-side state keyed by the inbound CallSid.
  • Includes the connector example lint fix required by current main.

Pre-Review Checklist

  • Build passes: Build, typecheck, and agent tests pass. Full lint reports one error in unchanged OpenAI plugin code; details below.
  • AI-generated code reviewed: Reviewed the change and removed unnecessary comments.
  • Changes explained: Behavior, cancellation ownership, and validation are documented below.
  • Scope appropriate: The prerequisite commits and connector example lint fix are explained above.
  • Video demo: Not supplied; this is a telephony flow. Historical live-call QA is described below.

Testing

  • Automated tests added/updated.

  • All local agent tests pass: 2,733 passed, 5 skipped (credentialed inference tests require separate credentials).

  • restaurant_agent.ts and realtime_agent.ts live smoke tests: Not run; this opt-in telephony change does not modify them. Both compile in the full workspace build.

  • CallToken and general warm-transfer Vitest suites: 51 passed across three files. Includes 21212 fallback, failed cancellation reporting, sensitive-error redaction, abort during connector setup, late call creation, and suppression of fallback after abort.

  • New lifecycle coverage uses real AgentSession and AgentActivity instances, mocking provider/transport boundaries. It verifies parent resumption after abort during connector setup, no subsequent dial, session shutdown while Twilio creation is pending, and cancellation of the late-created SID.

  • With Node 24 and the locked pnpm 11.13.1 dependencies: pnpm build (40 tasks), pnpm test agents --silent (163 files), pnpm typecheck, pnpm format:check, pnpm throws:check, core API Extractor, and REUSE licensing validation passed. The initial pnpm -w lint:fix passed with 193 warnings; the final pnpm lint after all packages were built reports @typescript-eslint/no-misused-promises at plugins/openai/src/ws/llm.ts:127, which is unchanged from the base. Changed-file lint passes. No unrelated plugin edits are included.

  • The first full test attempt hit sandbox socket/subprocess restrictions; the rerun with required local permissions passed.

  • Historical live QA on 2026-09-16 verified valid-token caller ID, business-number defaults, and invalid-token fallback in both SDKs. This revision uses deterministic tests; no new live PSTN calls were placed.

  • Upstream fork workflows may require maintainer approval; local validation is not a claim that GitHub Build/Test jobs ran.

Additional Notes

Feature scope discussion: livekit/agents#7375. This issue was opened after the implementation PRs; it is a request for maintainer scope review, not prior approval.

The transfer owns setup and the created call SID. Abort settles the caller-facing task promptly; late creation has its own cancellation handler and does not block activity/session teardown. Cancellation remains best effort while the process is alive. Success follows the existing consultation flow, while provider errors propagate through task settlement.

Python counterpart: livekit/agents#7309.

References

anunaym14 and others added 4 commits September 20, 2026 01:40
Warm transfers place a new outbound call, so the supervisor sees the
business number instead of the customer's. Add optional
`originalCallerNumber` and `twilioCallToken` to
TwilioConnectorWarmTransferTask so applications can present the inbound
caller's number using Twilio's CallToken.

- `twilioCallToken` opts in and requires `originalCallerNumber`.
- Without a token, dial from `twilioFromNumber` as before.
- On HTTP 400 / Twilio error 21210, retry once from the business number
  without the token. No retry on other errors or after a call exists.
- The token is sent only as the `CallToken` form field of the Calls API
  request. It is kept out of TwiML, connector requests, participant
  attributes, logs and error messages.
Wrap the async request handlers so the http server callback returns
void, satisfying @typescript-eslint/no-misused-promises on main.
@piyush-gambhir
piyush-gambhir requested a review from a team as a code owner September 19, 2026 20:10
@changeset-bot

changeset-bot Bot commented Sep 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 42a26f2

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 5 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment thread agents/src/workflows/warm_transfer.ts Outdated

/** Cancel a still-ringing Twilio call. */
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 19, 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 cancellation leaves calls active

When Twilio rejects cancellation, cancelTwilioCall logs and returns while the supervisor call remains active. The call can answer after the transfer fails and tears down its room.

Learn more

The task enforces ringingTimeout only while waiting locally for a connector audio track. Twilio receives no matching call timeout in createTwilioCall, so it controls the call until cancellation succeeds or its provider default expires. A transient 500 response therefore leaves the outbound call live even though the workflow reports failure and deletes the consultation room.

Example: The task gives up after 30 seconds and posts Status=canceled. Twilio returns 500, so the task finishes, but the supervisor's phone keeps ringing and can still be answered.

Recommended fix: For non-null ringingTimeout, send Twilio's Timeout field on both initial and fallback Calls requests, converted and bounded to Twilio's supported seconds range. Also retry transient cancellation failures with a short bounded backoff when practical.

Devin Review


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

Comment thread agents/src/workflows/warm_transfer.ts
Comment thread examples/src/connectors/twilio_connector.ts
Comment thread examples/src/connectors/whatsapp_connector.ts
Comment on lines +98 to +100
let body = '';
for await (const chunk of req) body += chunk;
const form = new URLSearchParams(body);

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.

🟨 Unbounded webhooks enable memory exhaustion

Webhook bodies are buffered before authentication without a size limit. An unauthenticated client can exhaust server memory with oversized requests.

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