Skip to content

feat(voice): full ConversationRelay TwiML customization for inbound and outbound#48

Open
ryanrouleau wants to merge 32 commits into
mainfrom
feat/twiml-customization-rebased
Open

feat(voice): full ConversationRelay TwiML customization for inbound and outbound#48
ryanrouleau wants to merge 32 commits into
mainfrom
feat/twiml-customization-rebased

Conversation

@ryanrouleau
Copy link
Copy Markdown
Collaborator

@ryanrouleau ryanrouleau commented May 14, 2026

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:

VoiceChannelConfig(
    default_twiml_options=TwiMLOptions(
        voice="en-US-Journey-D",
        language="en-US",
        interruptible="speech",
        languages=[LanguageConfig(code="es-MX", voice="es-MX-Neural2-A")],
    ),
)

Or hook in per-call logic for inbound:

async def by_country(req: TwiMLRequest) -> TwiMLOptions:
    if req.caller_country == "MX":
        return TwiMLOptions(language="es-MX", welcome_greeting="¡Hola!")
    return TwiMLOptions()

voice_channel.on_inbound_call_twiml(by_country)

For outbound, pass per-call data at the call site:

await voice_channel.initiate_outbound_conversation(
    InitiateVoiceConversationOptions(
        to="+1...",
        twiml_options=TwiMLOptions(custom_parameters={"campaign_id": "abc"}),
    )
)

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 TACFastAPIServer to TACConfig + 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:

  • TACFastAPIServer no longer accepts welcome_greeting or public_domain. Those fields are gone from TACServerConfig. Set TACConfig.voice_public_domain (or TWILIO_VOICE_PUBLIC_DOMAIN) for the public domain, and VoiceChannelConfig.default_twiml_options for the greeting.
  • InitiateVoiceConversationOptions no longer accepts welcome_greeting, action_url, or custom_parameters as flat fields. Pass them on twiml_options: InitiateVoiceConversationOptions(to=..., twiml_options=TwiMLOptions(welcome_greeting=...)) or on the VoiceChannelConfig's default_twiml_options.
  • TACFastAPIServer raises ValueError at construction when a voice channel is attached but no public URL is configured (no voice_public_domain, no websocket_url override). Previously the server would start and the misconfiguration only surfaced as a 500 on the first inbound call.
  • VoiceChannelConfig.websocket_url and action_url validate their schemes. websocket_url must start with ws:// or wss://; action_url must start with http:// or https://. Bare domains and mismatched schemes now raise at construction.

For custom adapters (Flask/Django/etc.) that bypass TACFastAPIServer and call the voice channel APIs directly:

  • VoiceEndpoints is 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 from TACConfig.voice_public_domain + VoiceChannelConfig paths.
  • generate_twiml(options)generate_twiml(websocket_url, options=None). WebSocket URL is now a required positional argument.
  • VoiceChannel raises ValueError on 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

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Refactoring

Checklist

  • Tests added/updated — full attribute coverage, <Language> emission, merge semantics across all layers, action_url precedence, deprecation forwarding, and an end-to-end server→channel→customizer flow.
  • Documentation updated — docstrings on VoiceChannelConfig, VoiceChannel.handle_incoming_call, initiate_outbound_conversation, TACFastAPIServer, and all new models. Example at getting_started/examples/features/voice_twiml_customization.py.
  • Tested E2E

SDK Parity

This is the Python SDK. If this change affects shared functionality, ensure the TypeScript SDK is updated as well.

  • Change is Python-specific (no TypeScript update needed)
  • TypeScript SDK PR created:

🤖 Generated with Claude Code

ryanrouleau and others added 14 commits May 14, 2026 11:47
…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>
@ryanrouleau ryanrouleau marked this pull request as ready for review May 14, 2026 17:52
Copilot AI review requested due to automatic review settings May 14, 2026 17:52
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 TwiMLRequest parsing + VoiceEndpoints to separate server-owned URLs from channel-owned TwiML options.
  • Expanded TwiMLOptions (and LanguageConfig) 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.

Comment thread src/tac/channels/voice/channel.py Outdated
Comment thread src/tac/channels/voice/channel.py
Comment thread src/tac/channels/voice/twiml.py Outdated
Comment thread src/tac/channels/voice/twiml.py
Comment thread src/tac/server/fastapi_server.py Outdated
ryanrouleau and others added 6 commits May 14, 2026 15:13
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>
@ryanrouleau ryanrouleau changed the title feat(voice): expose full TwiML customization via resolver + widened options feat(voice): full ConversationRelay TwiML customization for inbound and outbound May 18, 2026
ryanrouleau and others added 2 commits May 18, 2026 12:33
…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>
@ryanrouleau ryanrouleau requested a review from Copilot May 18, 2026 16:39
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Comment thread src/tac/models/outbound.py Outdated
Comment thread src/tac/server/fastapi_server.py Outdated
Comment thread getting_started/examples/features/outbound.py Outdated
ryanrouleau and others added 4 commits May 18, 2026 12:50
- 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)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

ryanrouleau and others added 2 commits May 19, 2026 13:21
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
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

websocket_url and action_url is constructed by the channel so no need to construct manually and pass in

Comment thread src/tac/models/voice.py
VoiceMessage = SetupMessage | PromptMessage | InterruptMessage


class ConversationRelayCallbackPayload(BaseModel):
Copy link
Copy Markdown
Collaborator Author

@ryanrouleau ryanrouleau May 19, 2026

Choose a reason for hiding this comment

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

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.",
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

welcome_greeting set here instead of on the server

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@ryanrishi ryanrishi left a comment

Choose a reason for hiding this comment

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

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 in model_extra
  • Injects a model_validator(mode="after") that emits DeprecationWarning for 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.

Comment thread src/tac/channels/voice/twiml.py
Comment thread src/tac/channels/voice/config.py Outdated
Comment on lines +83 to +92
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.",
)
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.

Is there any way to inherit these from the TACServerConfig (if using TAC Server), or enforce that the paths match somehow?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

ryanrouleau and others added 2 commits May 21, 2026 13:56
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 @@
)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@ryanrishi

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>
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.

3 participants