feat(voice): per-call TwiML overrides for custom hosts (host_twiml_options + websocket_url)#75
feat(voice): per-call TwiML overrides for custom hosts (host_twiml_options + websocket_url)#75ryanrouleau wants to merge 9 commits into
Conversation
…_call 2.0.0 (#48) centralized wss URL derivation: handle_incoming_call now derives the ConversationRelay WebSocket URL from TACConfig.voice_public_domain + voice_websocket_path, and TwiMLOptions dropped its websocket_url field. That introduced an asymmetry — outbound (initiate_outbound_conversation) can still override the URL per call via InitiateVoiceConversationOptions.websocket_url, but inbound had no per-call seam, and the on_inbound_call_twiml customizer can override every TwiML field except the URL. This blocks affinity-routed hosts (e.g. Azure Hosted Agents) that must append a per-call token (?agent_session_id={CallSid}) to the upgrade URL so the WS lands on the same warm sandbox that served the TwiML; their only recourse was to bypass the channel and call generate_twiml directly. Add an optional keyword-only websocket_url to handle_incoming_call, defaulting to the derived URL. Backward-compatible (existing callers and the FastAPI server are unaffected) and symmetric with the outbound seam. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because no GitHub Actions runner was available. Make sure your repository has a runner available to run Copilot's review, or add a copilot-setup-steps.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a per-call websocket_url override to VoiceChannel.handle_incoming_call to restore inbound/outbound symmetry and support affinity-routed deployments that need call-specific WebSocket URLs.
Changes:
- Extend
handle_incoming_callwith a keyword-onlywebsocket_url: str | None = Noneparameter that defaults to the existing derived URL. - Add tests to validate both the override behavior and the default URL derivation path.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/tac/channels/voice/channel.py | Adds a keyword-only websocket_url override and uses it when provided, otherwise falls back to derived URL. |
| tests/test_voice_channel.py | Adds regression tests for per-call websocket URL overrides and for default derivation behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ot a param Replace the standalone handle_incoming_call(websocket_url=...) param with a layered TwiMLOptions.websocket_url field, so per-call URL control flows through the existing on_inbound_call_twiml customizer instead of a separate method arg. #48 removed websocket_url from TwiMLOptions because it was `str = ""` — a "design lie" that could silently render url="". This restores it *safely* as `str | None = None`: unset falls through to the derived URL, so it can never produce url="". It rides the same merge as every other field — customizer > default_twiml_options > TAC default (TACConfig-derived URL, applied as the final `or` fallback, mirroring action_url). Precedent: action_url is already a TwiMLOptions field, and `extra` could already inject arbitrary <ConversationRelay> attrs — so this makes the most fundamental attribute typed and safe rather than only reachable untyped. - models/voice.py: add TwiMLOptions.websocket_url: str | None = None - channels/voice/twiml.py: register it in _HANDLED_OUTSIDE_LOOP (passed as the positional websocket_url arg to generate_twiml, not the generic attr loop) - channels/voice/channel.py: resolve merged.websocket_url or derived, for both inbound and outbound (outbound: options.websocket_url still wins) - tests: drive the override through on_inbound_call_twiml and default_twiml_options No new public method or parameter; backward-compatible (unset behaves as today). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… url from options Give a custom in-process server (one that holds a VoiceChannel and generates TwiML itself — e.g. Azure's TACHostedAgentsApp for Foundry Hosted Agents) a way to inject per-call transport facts without stealing the application's on_inbound_call_twiml hook. handle_incoming_call gains a keyword-only server_twiml_options: TwiMLOptions. It layers by specificity, matching the existing action_url precedence: TAC defaults → default_twiml_options → server_twiml_options → app customizer i.e. per-call server facts beat static channel defaults, and the application's per-call on_inbound_call_twiml customizer still wins over everything — a developer's explicit choice is never silently overridden by server plumbing. This is the layer through which the affinity-routed host supplies a per-call websocket_url (with e.g. an agent_session_id token) that default_twiml_options structurally can't express. Also make generate_twiml's positional websocket_url optional, falling back to options.websocket_url (positional wins when both given). This lets a channel-less caller (an edge server that builds its own per-call URL) pass everything in one object: generate_twiml(options=TwiMLOptions(websocket_url=...)). Raises ValueError if no URL is provided via either source. _build_twiml_options / _resolve_action_url now take (server, per_call) layers. Outbound passes (None, options.twiml_options) — no server layer for outbound. Tests: server_twiml_options sets per-call websocket_url; app customizer beats server layer (dev wins contested fields, server's uncontested websocket_url still applies); server layer beats default_twiml_options. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…te_twiml Cut the prose that repeated the precedence list and Args entries; keep the non-obvious behavior (layering order, positional-vs-options URL) stated once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sitional The options.websocket_url fallback was computed into resolved_websocket_url and validated, but <ConversationRelay> was still built from the raw positional websocket_url. So generate_twiml(options=TwiMLOptions(websocket_url=...)) with no positional arg passed the guard yet emitted <ConversationRelay> with no url — Twilio rejects that and drops the call. Use resolved_websocket_url for the url. Unreached by the channel's own call sites (they always pass a positional URL), but it's the documented channel-less path added in this branch. Tests: URL supplied via options only is emitted; positional wins over options; no URL via either source raises. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"server" collided with TACFastAPIServer — and misleadingly so, since that built-in server is precisely the one that does NOT pass this arg (only a custom in-process host does). "host" disambiguates and matches the framing used throughout: host-owned per-call transport facts (e.g. an affinity websocket_url). Renamed the public keyword arg on handle_incoming_call, the internal _build_twiml_options/_resolve_action_url `server` param → `host`, and all docstrings/tests. Unreleased and not yet adopted downstream, so this is a free rename now rather than a breaking change after Azure pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| # generic _OPTIONAL_RELAY_ATTRS loop) — the websocket_url (resolved through the | ||
| # layered merge and passed in as the positional ``websocket_url`` arg), the | ||
| # action_url, the <Language> children list, the <Parameter> children dict, and | ||
| # the extra escape hatch. |
| @@ -156,6 +156,8 @@ def _get_twilio_client(self) -> Client: | |||
| async def handle_incoming_call( | |||
There was a problem hiding this comment.
should we have two functions for this, one is for taking twiml_request, another is for taking host_twiml_options? i assume users will only choose one way to make it?
There was a problem hiding this comment.
They're two complimentary but different args:
twiml_requestis the server agnostic representation of the initial Twiml request from Twilio, handle_incoming_call then generates the twiml response internallyhost_twiml_optionsallows the server to inject specific twiml options/attributes into that generation. This enables our deployments where for example we need to add?session=<conversationId / callSid>for session affinity in the websocket URL for Foundry hosted agents or agentcore
There was a problem hiding this comment.
updated the docstring to make this clearer
…ling - TwiMLOptions.websocket_url docstring: correct the layering to include the host_twiml_options layer (customizer > host_twiml_options > default_twiml_options > TAC default) and point at handle_incoming_call's host_twiml_options rather than "the customizer". - twiml.py _HANDLED_OUTSIDE_LOOP comment: note websocket_url resolves from the positional arg OR options.websocket_url, not just the positional. - Reject empty/whitespace websocket_url at the model via a field_validator, so a misconfiguration fails fast instead of silently emitting <ConversationRelay url="">. With that guard, resolve the URL by None-coalescing (positional wins when both set) rather than truthiness, honoring the documented precedence. Tests: empty/whitespace websocket_url raises; None still falls through. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the inbound TwiML merge precedence so channel-wide default_twiml_options wins over per-call host_twiml_options. New order (high→low): on_inbound_call_twiml customizer > default_twiml_options > host_twiml_options > TAC defaults. - _build_twiml_options: overlay host first, then default_twiml_options - _resolve_action_url: check default_twiml_options.action_url before host - Update all precedence docs (handle_incoming_call, config.py inbound layers, voice.py websocket_url field) - Flip test: default_options now beats host_twiml_options Outbound path (host=None) is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
2.0.0 (#48) made
handle_incoming_callderive the ConversationRelay WebSocket URL fromTACConfigand removedwebsocket_urlfromTwiMLOptions. That leaves no seam for a custom host to supply a per-call URL — e.g. Azure Hosted Agents needswss://…/ws?agent_session_id={CallSid}for sandbox affinity.default_twiml_optionsis static, and hijackingon_inbound_call_twimlwould steal the app developer's hook.Change
TwiMLOptions.websocket_url: str | None = None— restores the field feat(voice): full ConversationRelay TwiML customization for inbound and outbound #48 removed, but safe: unset falls through to the derived URL (can't renderurl=""). Peer of the existingaction_url; layers like any other field.handle_incoming_call(*, host_twiml_options: TwiMLOptions | None = None)— a per-call layer for the host, without touching the app's customizer. Precedence (matches existingaction_urlresolution, ordered by specificity):The app's
on_inbound_call_twimlstill sits on top, so a developer's explicit choice is never silently overridden.generate_twiml(websocket_url=None, options=None)— positional URL now optional, falls back tooptions.websocket_url. Lets a channel-less edge caller (e.g. AWS AgentCore's Lambda) pass everything in one object. RaisesValueErrorif neither supplies a URL.Backward compatibility
Fully additive.
TACFastAPIServerand existing callers are unaffected (no host layer; unsetwebsocket_urlderives fromTACConfigas before).Tests
make check: ruff, mypy strict (75 files), 760 tests. New:host_twiml_optionslayering + precedence (dev beats host on contested fields, host's uncontestedwebsocket_urlstill applies);websocket_url/action_urllayering;generate_twimloptions-only path + no-URL guard. Also verified end-to-end against a live server (signed webhooks, real Conversation Orchestrator).Note for reviewers
Reverses #48's removal of
websocket_urlfromTwiMLOptions— deliberate, and safe via theNonedefault. Alternative considered: a dedicated server-URLs struct (VoiceServerURLs) — flagged so the API-shape call is explicit. Namedhost_notserver_to avoid colliding withTACFastAPIServer(which notably does not pass this — it's for custom in-process hosts).🤖 Generated with Claude Code