feat(voice): full ConversationRelay TwiML customization for inbound and outbound#48
feat(voice): full ConversationRelay TwiML customization for inbound and outbound#48ryanrouleau wants to merge 32 commits into
Conversation
…ptions
Rebased from twilio-innovation#74 onto current main. Conflict resolutions
reconcile with post-fork changes: memory_mode, TwiML signature validation,
the newer ConversationRelayCallbackPayload, and studio_voice_handoff_url
moving from the server into the voice channel.
Widens TwiMLOptions to cover voice, language, transcription_provider,
tts_provider, interruptible, dtmf_detection, debug, and adds <Language>
children via LanguageConfig. websocket_url defaults to "" so the server
can fill it in.
Adds VoiceChannelConfig.resolve_twiml_options — an optional async callable
receiving a framework-neutral TwiMLRequestContext (parsed Twilio webhook
fields) and returning TwiMLOptions overrides per call.
handle_incoming_call merge precedence (highest to lowest):
1. Resolver output (when configured and request_context given)
2. Caller-supplied options (typically server per-request defaults)
3. TAC defaults: welcome_greeting, conversation_configuration, and
action_url resolved via Studio handoff flow if configured, else
default_action_url.
Only fields explicitly set at a layer (model_fields_set) override lower
layers. action_url resolution moved from TACFastAPIServer into
VoiceChannel._resolve_action_url so framework-agnostic callers get the
Studio-handoff branching for free. In orchestrated mode the server
passes default_action_url=None (preserves existing behavior of omitting
<Connect action=...>); in relay-only mode the server passes its callback
URL.
Adds getting_started/examples/features/twiml_customization.py showing
per-call voice/language selection by caller country with <Language>
children.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ize, fix action_url precedence Design refinements after PR review: 1. Add `VoiceChannelConfig.twiml_options` for static per-channel TwiML defaults (voice, language, etc.) — no callback needed for the common case of "same ConversationRelay config on every call." 2. Add `VoiceChannelConfig.welcome_greeting` shortcut so the 80% case doesn't require constructing a TwiMLOptions. 3. Rename `resolve_twiml_options` → `customize_twiml_options`. `resolve` was overloaded jargon; `customize` accurately describes what the user's function does (modify defaults, not replace them) and pairs cleanly with the noun field `twiml_options`. 4. Deprecate `TACServerConfig.welcome_greeting` — it belongs on the voice channel, not server plumbing. Emits DeprecationWarning when set, forwards the value to VoiceChannelConfig only when the channel didn't set its own. One release from removal. 5. Fix action_url precedence: resolver → caller → channel static → default → Studio handoff. `default_action_url` now beats Studio handoff so relay-only session cleanup callbacks always fire, even when a Studio flow is configured for handoff in other contexts. New merge precedence in VoiceChannel.handle_incoming_call (highest → lowest): 1. customize_twiml_options output 2. caller-supplied options (server plumbing: websocket_url) 3. VoiceChannelConfig.twiml_options (static) 4. TAC defaults (welcome_greeting, conversation_configuration, action_url) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…MLOptions
Make the server → channel boundary explicit. TwiMLOptions was the user-facing
TwiML override model but also carried websocket_url with default="" so the
server could inject it later — a design lie that would silently produce
url="" in TwiML if anyone constructed TwiMLOptions manually.
New shape:
class VoiceServerURLs(BaseModel):
websocket_url: str # required
conversation_relay_callback_url: str | None = None # relay-only cleanup
async def handle_incoming_call(
server_urls: VoiceServerURLs,
request_context: TwiMLRequestContext | None = None,
) -> str: ...
Server builds VoiceServerURLs from public_domain + path. TwiMLOptions now
only holds fields users legitimately set (voice, language, greeting,
action_url, etc.). websocket_url is no longer a field on it. generate_twiml
takes websocket_url as a separate positional arg.
Merge layers collapse from four to three — caller-supplied options is gone:
1. customize_twiml_options output
2. VoiceChannelConfig.twiml_options (static)
3. TAC defaults (welcome_greeting, conversation_configuration, action_url
from server_urls.conversation_relay_callback_url or Studio handoff)
Custom adapters for other web frameworks (Flask, Django, …) now build
VoiceServerURLs themselves instead of reconstructing an f-string for the
websocket URL — the server → channel contract is the struct, not a field
on an unrelated model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/twiml request.form() on a POST with the body shapes Twilio sends doesn't fail in any normal path. Silently falling back to an empty dict meant the customizer would run on zero context with no diagnostic — real failures (framework bug, buffer errors) should surface as 500s, not get swallowed. Also filter non-string values instead of str()-coercing them. FastAPI's form parser can return UploadFile for multipart uploads; Twilio doesn't send those, but if one ever appeared we'd rather skip it than str() a file handle into the context. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rl → action_url Twilio calls this the "action URL" (the <Connect action="..."> attribute), and we already have TwiMLOptions.action_url that maps to the same TwiML attribute. The old name was describing the server's route path (/conversation-relay-callback) rather than the TwiML concept. The two fields don't collide at the call site because they live on different types — VoiceServerURLs.action_url (server-supplied default) vs TwiMLOptions.action_url (user-set override). Also retighten the server comment at the construction site: the reason we omit action_url in orchestrated mode isn't "CO manages lifecycle," it's that passing it would shadow Studio handoff (when configured). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…efault The server used to conditionally omit action_url in orchestrated mode so it wouldn't shadow Studio handoff. That meant server behavior silently changed based on a field on an unrelated config (TACConfig.studio_handoff_flow_sid), and the reason why was hidden in a comment. Flip the precedence instead: customizer → static twiml_options → Studio handoff → server_urls.action_url User-expressed intent (Studio handoff set explicitly) beats the SDK's generated cleanup default. Server unconditionally passes its cleanup URL; channel picks the right one based on what the user configured. Trade-off: a user who sets both studio_handoff_flow_sid and runs in relay-only mode will have Studio win (not cleanup). That combination is contradictory — if they wanted cleanup, they shouldn't configure Studio handoff. They can opt out by returning the cleanup URL from a customizer. The server's /twiml handler drops its orchestrator check and the misleading comment about why it was there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ontext → twiml_request
"Request context" was vague and mixed directions ("TwiML" is output,
"Request" is input). The model represents the Twilio TwiML request —
what Twilio POSTs to your /twiml endpoint asking for TwiML — so name
it for that.
Pairs cleanly with TwiMLOptions:
TwiMLRequest (what came in) → TwiMLOptions (what goes out)
The field on handle_incoming_call is now `twiml_request`, and customizer
signatures read naturally: `async def customize(req: TwiMLRequest) ...`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The example previously showed only customize_twiml_options, which is the most advanced path. Most users want the static case — same voice/language on every call — and should reach for welcome_greeting or twiml_options first. Restructure the example so the default active code demonstrates the static path (twiml_options + welcome_greeting shortcut), with the per-call customizer as a commented-out block showing localization by caller country. Add a header summarizing all three layers and when to pick which. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion.py voice_ prefix groups voice-related examples alongside voice_streaming.py without a directory reshuffle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… → endpoints "Endpoint" carries domain meaning (a URL something calls to do X); "ServerURLs" was a data-type description. Endpoints also covers future extension cleanly — if we add, say, a status_callback_url, it's still obviously an endpoint. Call site reads naturally: await channel.handle_incoming_call(endpoints, twiml_request=req) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ersationRelay attrs
Every time Twilio ships a new ConversationRelay attribute, users had to
wait for a TAC release. The typed path is still the default (autocomplete,
validation, merge semantics), but extra lets users pass through anything
the SDK hasn't caught up with yet:
TwiMLOptions(extra={"new_feature": "on"})
The twilio SDK still does snake_case → camelCase conversion, so users
write Python conventions and get valid TwiML out.
If a user puts a typed-field name in extra (e.g., extra={"voice": ...}),
the typed field wins and a warning is logged — this is almost always a
mistake.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shortcut duplicated TwiMLOptions.welcome_greeting and set a precedent we couldn't keep — why not shortcut `voice` or `language` too? Two-ways-to-do-the-same-thing is a smell, and it created a hidden precedence question (shortcut vs twiml_options) users shouldn't have to reason about. Now welcome_greeting lives where other TwiML attributes live — TwiMLOptions. One place, one concept. Precedence for the greeting is cleaner too: customizer > twiml_options > deprecated server value > SDK default The deprecated TACServerConfig.welcome_greeting forwards to a separate channel slot (_deprecated_server_welcome_greeting) used only as a fallback — so a user who has twiml_options.welcome_greeting set always wins over the legacy server setting, as expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audited the model against Twilio's ConversationRelay docs and added the 14 attributes we were missing, plus speech_model on <Language>: welcome_greeting_interruptible tts_language transcription_language speech_model elevenlabs_text_normalization eot_threshold partial_prompts deepgram_smart_format speech_timeout interrupt_sensitivity report_input_during_agent_speech ignore_backchannel preemptible hints events intelligence_service Typed where Twilio documents enums (Literal): welcome_greeting_interruptible, interruptible, interrupt_sensitivity, report_input_during_agent_speech, elevenlabs_text_normalization. Pydantic validates range constraints for eot_threshold (0.5-0.9) and speech_timeout (600-5000ms). Also fixed the stale `debug` docstring — Twilio moved speaker-events and tokens-played to a new `events` attribute; the old docstring told users to pass them via `debug`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rify docs - Fix dead welcome_greeting kwarg on VoiceChannelConfig in a test that survived the shortcut removal because Pydantic silently ignored it. Move the greeting into the adjacent TwiMLOptions where it belongs. - Set extra="forbid" on VoiceChannelConfig so typos and stale field names fail loudly instead of silently no-opping. Zero test churn (full suite still passes), so no other dead kwargs lurking. - Tighten the example docstring to say defaults sit under twiml_options, and add a forward-looking note on _overlay_fields that nested models and lists replace wholesale by design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR expands the voice channel’s TwiML customization surface so applications can control most (or all) ConversationRelay attributes without reimplementing the FastAPI server. It introduces structured request context parsing for Twilio’s /twiml webhook, adds static + per-request TwiML option layering on VoiceChannel, and widens TwiMLOptions to cover many more ConversationRelay attributes (including <Language> children).
Changes:
- Added
TwiMLRequestparsing +VoiceEndpointsto separate server-owned URLs from channel-owned TwiML options. - Expanded
TwiMLOptions(andLanguageConfig) and updated TwiML generation to emit the new attributes and children. - Added per-channel static TwiML options plus an async customizer callback, and adjusted server/tests/examples accordingly.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/tac/models/voice.py |
Adds TwiMLRequest, VoiceEndpoints, LanguageConfig, and significantly widens TwiMLOptions. |
src/tac/channels/voice/twiml.py |
Refactors TwiML generation to accept websocket_url separately and emit many more ConversationRelay attributes + <Language> children. |
src/tac/channels/voice/config.py |
Introduces static twiml_options and async customize_twiml_options on VoiceChannelConfig. |
src/tac/channels/voice/channel.py |
Implements layered merge semantics, moves action_url resolution into the channel, and updates handle_incoming_call API. |
src/tac/server/fastapi_server.py |
Parses Twilio form data into TwiMLRequest and passes VoiceEndpoints into the channel. |
src/tac/server/config.py |
Deprecates TACServerConfig.welcome_greeting with a warning and changes its default to None. |
tests/test_voice_models.py |
Adds unit tests for TwiMLRequest.from_form, LanguageConfig, and VoiceEndpoints. |
tests/test_voice_channel.py |
Updates existing tests and adds coverage for expanded TwiML attributes and merge semantics. |
tests/test_server.py |
Updates server defaults and adds end-to-end customizer + deprecated welcome_greeting forwarding tests. |
tests/test_relay_only_mode.py |
Updates relay-only TwiML tests to use VoiceEndpoints + channel static options. |
getting_started/examples/features/voice_twiml_customization.py |
Adds a usage example showing static and per-call TwiML customization. |
Comments suppressed due to low confidence (1)
src/tac/server/fastapi_server.py:212
- Since post_twiml() now points at conversation_relay_callback_path by default, that callback endpoint becomes part of the normal call flow. It currently has no Twilio signature validation dependency, unlike /twiml and /webhook. Consider adding the same http signature dependency to the conversation_relay_callback route to prevent spoofed cleanup callbacks.
endpoints = VoiceEndpoints(
websocket_url=f"wss://{config.public_domain}{config.websocket_path}",
action_url=(
f"https://{config.public_domain}{config.conversation_relay_callback_path}"
),
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Outbound TwiML previously ignored the channel's TwiML config and only
accepted three flat fields (welcome_greeting, action_url,
custom_parameters) on InitiateVoiceConversationOptions. Users who set
voice, language, interruptible, <Language> children, etc. on the channel
got those settings on inbound calls but not outbound — the same user
configuring the same channel got different behavior depending on who
initiated the call.
Outbound now uses the same merge machinery as inbound:
1. per-call options.twiml_options (new field)
2. VoiceChannelConfig.twiml_options (channel-wide)
3. TAC defaults (welcome greeting, conversation_configuration,
action_url from Studio handoff if configured)
No customizer layer on outbound — customizers receive a TwiMLRequest
from an inbound Twilio webhook, which doesn't exist for outbound.
The three flat fields (welcome_greeting, action_url, custom_parameters)
are deprecated with DeprecationWarning and forwarded into twiml_options
at validation time. Explicit twiml_options values win over flat-field
fallbacks. Same pattern as TACServerConfig.welcome_greeting deprecation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make it explicit that twiml_options layers merge per-field, not wholesale — setting twiml_options=TwiMLOptions(voice="X") on a call overrides only voice; other fields still inherit from VoiceChannelConfig.twiml_options. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The outbound example was passing twiml_options inline with no explanation of why at the call site vs. on the channel. Add a comment pointing at both layers and when to use which (per-recipient values per-call; stable voice/language on the channel). The TwiML customization example didn't mention that channel twiml_options applies to outbound too, or that the customizer is inbound-only. Add a one-sentence note so readers know which tool fits their call direction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lent The comment had grown into a design doc. Users reading an example just need to know per-call exists, inbound has its own equivalent, and channel-wide covers the rest. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e for scope clarity
Two related cleanups, committed together because the call-site changes
overlap.
1. Move WebSocket and action URLs from per-call VoiceEndpoints onto
VoiceChannelConfig. The voice channel now reads its URLs from
self.config (set once at startup), not from arguments threaded
through handle_incoming_call. TACFastAPIServer populates them in
__init__ from public_domain + paths and now raises if public_domain
is missing when a voice channel is configured (was a soft warning).
- VoiceEndpoints model removed entirely.
- handle_incoming_call(twiml_request=...) — no more endpoints arg.
- initiate_outbound_conversation: websocket_url is now optional on
InitiateVoiceConversationOptions, falling back to the channel's
configured URL.
- Custom adapters (Flask, Django, etc.) set websocket_url/action_url
on VoiceChannelConfig directly — same field, one place.
2. Rename for scope-explicit naming on VoiceChannelConfig:
twiml_options → default_twiml_options
customize_twiml_options → customize_inbound_twiml
The duplicate `twiml_options` name on VoiceChannelConfig vs.
InitiateVoiceConversationOptions was confusing — same name, different
scope. `default_twiml_options` makes the channel-wide default explicit;
`customize_inbound_twiml` makes the inbound-only scope visible at the
call site (and the absence of a `customize_outbound_twiml` becomes a
deliberate-looking gap rather than oversight).
Add a top-of-class merge-pipeline summary on VoiceChannelConfig so the
full layering is documented in one place instead of scattered across
four method docstrings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ion hygiene - Update docstrings, comments, and deprecation messages still using the pre-rename names (twiml_options / customize_twiml_options) in outbound.py and the voice_twiml_customization example. Note that InitiateVoiceConversationOptions.twiml_options keeps its name (it's per-call on a different type); only VoiceChannelConfig fields renamed. - Re-export TwiMLOptions, TwiMLRequest, LanguageConfig, InterruptMode, and InboundTwiMLCustomizer from tac.channels.voice so users have one import path for voice-feature types instead of two. - Replace the cross-component poke voice_channel._deprecated_server_welcome_greeting = ... with an intentional internal API _set_deprecated_server_welcome_greeting(). The contract is now explicit and the method docstring names what to remove when the deprecated TACServerConfig.welcome_greeting field is deleted. - Align /twiml and /conversation-relay-callback form parsing: both now filter non-string values via isinstance(v, str) instead of one filtering and the other str()-coercing. - Add a clarifying note on TwiMLRequest.extra explaining why its dict values are str-only (webhook form fields) while TwiMLOptions.extra accepts str | bool | int (emitted TwiML attributes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…drift
Address review feedback:
- Normalize `interruptible=True/False` to the documented enum values
("any"/"none") before emitting TwiML. Twilio accepts the booleans for
backward-compat but the documented values are the four-element enum.
Twilio's SDK was emitting interruptible="true"/"false" verbatim; now
we convert in generate_twiml.
- Add an import-time check (_verify_attrs_in_sync) that fails loudly if
TwiMLOptions grows a field that twiml.py doesn't account for — either
in _OPTIONAL_RELAY_ATTRS (emitted as a ConversationRelay attribute) or
_HANDLED_OUTSIDE_LOOP (action_url/languages/custom_parameters/extra).
- Document explicit-None semantics on _resolve_action_url: setting
action_url=None on a layer falls through to the next layer; suppressing
<Connect action=...> requires unsetting everywhere (no Studio handoff,
no channel default).
- Add a load-bearing comment on _overlay_fields explaining why action_url
is skipped — letting it through would let a higher-priority layer that
didn't set action_url silently clobber a lower layer that did.
- Cover the three-layer action_url interaction with a new test
(customizer with no action_url + default_twiml_options.action_url +
channel.config.action_url → default wins).
- Document that TwiMLOptions.extra coerces values: bool → "true"/"false",
int → stringified, snake_case keys → camelCase via the Twilio SDK.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The route was previously unprotected — pre-existing miss, but newly relevant because this PR makes the route reachable on every voice call (action_url is no longer omitted in orchestrated mode). Add the same http_sig dependency every other Twilio webhook route uses, with two tests covering missing/valid signatures. Also addressed two doc/precision nits: - _overlay_fields docstring called extra a "list"; it's a dict. - _deprecated_server_welcome_greeting falls back via `is not None` instead of truthiness, so an explicit empty string isn't silently replaced by DEFAULT_WELCOME_GREETING. Edge case but free precision. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Update outbound.py example comment to reference the current API names (customize_inbound_twiml, default_twiml_options) instead of the pre-rename ones. - Treat empty-string websocket_url/action_url on VoiceChannelConfig as unset in TACFastAPIServer._configure_voice_channel_urls, matching VoiceChannel._require_websocket_url's truthiness check. Previously a user who passed empty strings would skip auto-population and hit the channel's missing-URL error at request time. - Add a regression test asserting that deprecated flat fields forwarded via the model_validator end up in twiml_options.model_fields_set — load-bearing because the merge layer treats unset fields as fallthrough. (Pydantic v2's setattr does correctly mutate model_fields_set, so this works today; the test prevents regression.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small simplifications driven by reading the diff with a "what's overcomplicated" lens: 1. Forward the deprecated TACServerConfig.welcome_greeting through the channel's default_twiml_options.welcome_greeting instead of carrying a separate _deprecated_server_welcome_greeting state on the channel. The deprecated value flows through the normal merge pipeline like any other channel-wide default. Removes a private setter, an instance attribute, and conditional logic at two call sites. The deletion path when the deprecated field goes away is now a single _forward_deprecated_welcome_greeting() method on the server. 2. Extract _build_twiml_options(per_call) — handle_incoming_call and initiate_outbound_conversation built the same merged TwiMLOptions structure with ~15 lines of identical code. Inbound now invokes the customizer first, then both call into the helper. 3. Derive TwiMLRequest.from_form's known_aliases set from model_fields instead of maintaining a parallel literal that could drift if a field is added or removed. Side effect of (1): the channel now reads default_twiml_options and customize_inbound_twiml from self.config at use-time instead of snapshotting them at __init__. This is correct — the config is the source of truth, and the snapshot would have been stale after the server's deprecation forwarding. Net: -29 lines, fewer private surfaces, simpler deletion path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
outbound.py: - Add `default_twiml_options` on VoiceChannelConfig so the example shows channel-wide defaults applied to every outbound call. Per-call twiml_options on the call site demonstrates the layering (per-call wins). - Drop the websocket_url and action_url that were threaded through the call site — TACFastAPIServer populates both from public_domain at startup, so passing them again teaches a bad pattern. voice_twiml_customization.py: - Uncomment the customizer block so the active code shows both layers together (channel-wide defaults + per-call inbound customizer). That's the realistic shape; "either/or" framing was misleading. - Strip the example to attributes that work consistently across accounts (welcome_greeting, interruptible, language). Dropped the voice and tts_provider/transcription_provider settings — those rely on provider/voice combos that aren't always valid on every Twilio account configuration and obscure what the example is teaching. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…izer on channel
Two related shifts that simplify the voice channel's contract.
1. URL ownership moves from server to TAC/channel config
TACFastAPIServer used to reach into voice_channel.config and mutate its
URL fields at construction. That implicit coupling created an
undocumented contract for custom adapters: "you must populate these
URLs before the first call or you'll fail at request time."
Now:
- TACConfig.voice_public_domain holds the public domain (read from
TWILIO_VOICE_PUBLIC_DOMAIN at TACConfig.from_env). Deployment fact,
used by voice regardless of which web framework registers routes.
- VoiceChannelConfig owns websocket_path / action_path (defaults
/ws and /conversation-relay-callback) plus optional websocket_url /
action_url overrides for cross-domain or proxy setups.
- VoiceChannel resolves URLs at use-time:
override → derive from voice_public_domain + path → raise (websocket)
or None (action_url falls through to higher layers).
- TACFastAPIServer drops _configure_voice_channel_urls. It registers
routes at TACServerConfig.websocket_path / conversation_relay_callback_path
(its own concern). The channel constructs URLs from
VoiceChannelConfig.websocket_path / action_path (its own concern).
Defaults match so the common case "just works"; users who customize
one keep the other in sync, or use the websocket_url override for
proxy setups where they diverge.
- TACServerConfig.public_domain is deprecated and forwarded to
TACConfig.voice_public_domain.
2. Per-call inbound customizer moves to a method on VoiceChannel
VoiceChannelConfig.customize_inbound_twiml = callable was awkward —
behavior on a config model. Pydantic models hold data; functions are
behavior. Splitting fits TAC's existing handler vocabulary
(tac.on_message_ready, tac.on_interrupt, etc.) and prepares for future
ConversationRelay event hooks (on_dtmf, on_prompt, …) that will follow
the same on_X-method-on-channel pattern.
API:
voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(
default_twiml_options=TwiMLOptions(...),
))
async def by_country(req: TwiMLRequest) -> TwiMLOptions: ...
voice_channel.on_inbound_call_twiml(by_country)
The static layer (default_twiml_options) stays on VoiceChannelConfig —
it's data. The dynamic layer (on_inbound_call_twiml) is a method —
it's a handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| ), | ||
| ) | ||
| # Register the per-call inbound customizer. | ||
| voice_channel.on_inbound_call_twiml(customize_twiml) |
There was a problem hiding this comment.
Putting this callback here, in anticipation of more crelay callbacks being added to the voice channel in the near future, like:
voice_channel.on_inbound_call_twiml()
voice_channel.on_interrupt() # move from tac.on_interrupt()
voice_channel.on_dtmf()
...
Idea being omnichanell / TAC wide callbacks are registered directly on tac like on_message_ready. Channel specific lifecycle is on channel specifically to keep things clean / don't spread crelay stuff to core TAC
Drops TACServerConfig.public_domain and welcome_greeting, the flat welcome_greeting/action_url/custom_parameters on InitiateVoiceConversationOptions, and the forwarding/warning paths that translated them into the new shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- _resolve_action_url now respects explicit action_url=None on a layer as suppression intent (using model_fields_set), instead of falling through to lower layers. Set action_url=None on the customizer or default_twiml_options to disable <Connect action=...> for that scope. - TACConfig.voice_public_domain strips whitespace, schemes (https://, wss://, etc.), and trailing slashes at parse time so a copy-pasted https://example.ngrok.app/ doesn't produce wss://https://example.ngrok.app//ws. - VoiceChannelConfig.websocket_url and action_url validate their schemes (ws/wss and http/https respectively) — bare domains now fail at construction with a clear message. - TACFastAPIServer raises at __init__ if a voice channel is attached but neither voice_public_domain nor websocket_url is set, rather than letting the misconfiguration surface as a 500 on the first inbound call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| websocket_url=f"wss://{public_domain}/ws", | ||
| welcome_greeting=args.welcome_greeting, | ||
| action_url=f"https://{public_domain}/conversation-relay-callback", | ||
| # Per-call TwiML overrides for this outbound call. Overrides channel defaults |
There was a problem hiding this comment.
websocket_url and action_url is constructed by the channel so no need to construct manually and pass in
| VoiceMessage = SetupMessage | PromptMessage | InterruptMessage | ||
|
|
||
|
|
||
| class ConversationRelayCallbackPayload(BaseModel): |
There was a problem hiding this comment.
Adding types for all crelay twiml options. There's an optional extra field if crelay adds new twiml that we don't have typed
| config=VoiceChannelConfig( | ||
| # Channel-wide defaults — apply to every call (inbound + outbound). | ||
| default_twiml_options=TwiMLOptions( | ||
| welcome_greeting="Hello! This is a default greeting.", |
There was a problem hiding this comment.
welcome_greeting set here instead of on the server
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ryanrishi
left a comment
There was a problem hiding this comment.
Is there a way to deprecate the fields likewelcome_greeting, action_url, custom_parameters? Otherwise these fields will be silently ignored since the model config doesn't set extra="forbid". Callers migrating from v1.0.1 won't get any signal that their code stopped working.
We can add a deprecated_fields class decorator + utility that:
- Sets
extra="allow"so Pydantic captures unknown fields inmodel_extra - Injects a
model_validator(mode="after")that emitsDeprecationWarningfor recognized old field names - Stores metadata in
__deprecated_fields__(PEP 702 convention) for future doc generator support
# src/tac/utils/deprecation.py
@dataclass(frozen=True)
class DeprecatedInfo:
since: str
removal: str | None = None
alternative: str | None = None
@property
def message(self) -> str:
msg = f"Deprecated in tac {self.since}."
if self.removal:
msg += f" Will be removed in {self.removal}."
if self.alternative:
msg += f" Use {self.alternative} instead."
return msg
def deprecated_fields(**fields: DeprecatedInfo):
"""Class decorator for Pydantic models with removed fields."""
...Call site:
@deprecated_fields(
welcome_greeting=DeprecatedInfo(
since="1.0.2", removal="2.0",
alternative="twiml_options=TwiMLOptions(welcome_greeting=...)",
),
action_url=DeprecatedInfo(
since="1.0.2", removal="2.0",
alternative="twiml_options=TwiMLOptions(action_url=...)",
),
custom_parameters=DeprecatedInfo(
since="1.0.2", removal="2.0",
alternative="twiml_options=TwiMLOptions(custom_parameters=...)",
),
)
class InitiateVoiceConversationOptions(BaseModel):
model_config = {"populate_by_name": True, "extra": "allow"}
...Warning output:
InitiateVoiceConversationOptions.welcome_greeting was deprecated in tac 1.0.2 and will be removed in 2.0. Use twiml_options=TwiMLOptions(welcome_greeting=...) instead. The value passed here is being ignored.
This also works as a standalone decorator for methods/classes (@deprecated(since="1.0.2", ...)), and supports per-field version timelines when fields are deprecated at different points.
This won't be the last time we deprecate something so it might be worth wiring a deprecation util up now so we can reuse it in the future.
| websocket_path: str = Field( | ||
| default="/ws", | ||
| description="Path the voice WebSocket is served at. Combined with " | ||
| "TACConfig.voice_public_domain to build the WebSocket URL.", | ||
| ) | ||
| action_path: str = Field( | ||
| default="/conversation-relay-callback", | ||
| description="Path the ConversationRelay action callback is served at. " | ||
| "Combined with TACConfig.voice_public_domain to build the action URL.", | ||
| ) |
There was a problem hiding this comment.
Is there any way to inherit these from the TACServerConfig (if using TAC Server), or enforce that the paths match somehow?
There was a problem hiding this comment.
I'm taking a second look at this. Originally split to support the case where TAC's public URL may not match it's internal URL (e.g. like behind a proxy or gateway) but it's complicating things
There was a problem hiding this comment.
Ok I simplified this and abandoned the use case of splitting the URLs (can revisit if needed). The source of truth of the paths are now on TACConfig which everything reads from
Voice URL construction now has a single source of truth. Paths (voice_websocket_path, voice_action_path) live on TACConfig alongside voice_public_domain — both VoiceChannel (for URL construction) and TACFastAPIServer (for route registration) read from the same fields, so drift is structurally impossible. Drops the speculative websocket_url / action_url overrides on VoiceChannelConfig (introduced earlier in this PR; never existed on main). Per-call outbound override (InitiateVoiceConversationOptions.websocket_url) stays — it's the only way to get dynamic-per-call URLs and predates this PR. Also tightens TwiMLOptions docstrings to clarify scope: configures the TwiML inside <ConversationRelay> (plus <Connect action>), not the verbs around it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…und options
Three small cleanups discovered during audit:
- Forbid extra fields on InitiateVoiceConversationOptions. Pydantic's
default extra="ignore" silently dropped the fields removed earlier in
this PR (welcome_greeting, action_url, custom_parameters); forbidding
turns those into ValidationError so callers upgrading from older TAC
get a clear migration signal. Tests cover all three.
- Drop the TwiML-customization paragraph from TACFastAPIServer's
docstring. It duplicated docs that already live on VoiceChannelConfig
and on_inbound_call_twiml, where they belong.
- Inline the env-var path overrides on TACConfig.from_env using
os.environ.get(name, default), matching the existing log_level
pattern. Removes the temp dict and **spread.
- Remove a stale WHAT comment ("Invoke the customizer if configured")
whose code is self-evident.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| @@ -41,8 +41,8 @@ | |||
| ) | |||
There was a problem hiding this comment.
Is there a way to deprecate the fields likewelcome_greeting, action_url, custom_parameters? Otherwise these fields will be silently ignored since the model config doesn't set extra="forbid". Callers migrating from v1.0.1 won't get any signal that their code stopped working.
Added extra="forbid" here to throw an error for users that upgrade. Originally had these fields deprecated throughout but ended up just removing them after our stand up discussion a few days ago
- speech_timeout: accept int | Literal["auto"] | None to allow restoring the platform default from a higher merge layer; drop the client-side range check that drifts when Twilio changes its bounds (let Twilio be the source of truth) - eot_threshold: same — drop the ge=0.5/le=0.9 client-side gate - TwiMLOptions.extra: raise at construction when a key shadows a typed field instead of silently dropping it; covers both the "typed value set" and "typed value unset" cases. Drops the warn-and-discard branch in twiml.py - VoiceChannelConfig.default_twiml_options: short docstring pointer to _overlay_fields for the wholesale-replace gotcha - Trim 14 redundant tests (~150 LOC) that either tested the Twilio SDK, duplicated coverage, or asserted f-string interpolation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Developers using TAC's voice channel previously couldn't customize TwiML beyond
welcome_greeting— the rest of the ConversationRelay surface was hardcoded. This PR opens it up.What you can do now
Configure TwiML once on
VoiceChannelConfig:Or hook in per-call logic for inbound:
For outbound, pass per-call data at the call site:
Both directions share
default_twiml_options. Per-call layers win over channel defaults; layers merge per-field (only fields you explicitly set override lower layers).Centralized URL construction
Voice URL construction (websocket + action URLs) moved from
TACFastAPIServertoTACConfig+VoiceChannel. The server registers routes; the channel owns the URLs. This makes the voice channel usable from custom adapters (Flask/Django/etc.) without re-plumbing URLs through the server. See Breaking changes for the API shape.Breaking changes
URL construction moved off
TACFastAPIServer. If you've configured TAC for voice before, the changes you'll notice:TACFastAPIServerno longer acceptswelcome_greetingorpublic_domain. Those fields are gone fromTACServerConfig. SetTACConfig.voice_public_domain(orTWILIO_VOICE_PUBLIC_DOMAIN) for the public domain, andVoiceChannelConfig.default_twiml_optionsfor the greeting.InitiateVoiceConversationOptionsno longer acceptswelcome_greeting,action_url, orcustom_parametersas flat fields. Pass them ontwiml_options:InitiateVoiceConversationOptions(to=..., twiml_options=TwiMLOptions(welcome_greeting=...))or on theVoiceChannelConfig'sdefault_twiml_options.TACFastAPIServerraisesValueErrorat construction when a voice channel is attached but no public URL is configured (novoice_public_domain, nowebsocket_urloverride). Previously the server would start and the misconfiguration only surfaced as a 500 on the first inbound call.VoiceChannelConfig.websocket_urlandaction_urlvalidate their schemes.websocket_urlmust start withws://orwss://;action_urlmust start withhttp://orhttps://. Bare domains and mismatched schemes now raise at construction.For custom adapters (Flask/Django/etc.) that bypass
TACFastAPIServerand call the voice channel APIs directly:VoiceEndpointsis removed. The channel reads URLs from its own config now instead of receiving them per-call.VoiceChannel.handle_incoming_call(endpoints, twiml_request=None)→handle_incoming_call(twiml_request=None). URLs are derived fromTACConfig.voice_public_domain+VoiceChannelConfigpaths.generate_twiml(options)→generate_twiml(websocket_url, options=None). WebSocket URL is now a required positional argument.VoiceChannelraisesValueErroron first request if it can't resolve a WebSocket URL. Previously the server emitted a soft warning that produced malformed TwiML at runtime.Type of Change
Checklist
<Language>emission, merge semantics across all layers,action_urlprecedence, deprecation forwarding, and an end-to-end server→channel→customizer flow.VoiceChannelConfig,VoiceChannel.handle_incoming_call,initiate_outbound_conversation,TACFastAPIServer, and all new models. Example atgetting_started/examples/features/voice_twiml_customization.py.SDK Parity
This is the Python SDK. If this change affects shared functionality, ensure the TypeScript SDK is updated as well.
🤖 Generated with Claude Code