Skip to content

feat(voice): per-call TwiML overrides for custom hosts (host_twiml_options + websocket_url)#75

Open
ryanrouleau wants to merge 9 commits into
mainfrom
feat/inbound-websocket-url-override
Open

feat(voice): per-call TwiML overrides for custom hosts (host_twiml_options + websocket_url)#75
ryanrouleau wants to merge 9 commits into
mainfrom
feat/inbound-websocket-url-override

Conversation

@ryanrouleau

@ryanrouleau ryanrouleau commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Problem

2.0.0 (#48) made handle_incoming_call derive the ConversationRelay WebSocket URL from TACConfig and removed websocket_url from TwiMLOptions. That leaves no seam for a custom host to supply a per-call URL — e.g. Azure Hosted Agents needs wss://…/ws?agent_session_id={CallSid} for sandbox affinity. default_twiml_options is static, and hijacking on_inbound_call_twiml would steal the app developer's hook.

Change

  1. 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 render url=""). Peer of the existing action_url; layers like any other field.

  2. handle_incoming_call(*, host_twiml_options: TwiMLOptions | None = None) — a per-call layer for the host, without touching the app's customizer. Precedence (matches existing action_url resolution, ordered by specificity):

    TAC defaults → default_twiml_options → host_twiml_options → on_inbound_call_twiml
      (SDK)         (dev, static)           (host, per-call)      (dev, per-call — wins)
    

    The app's on_inbound_call_twiml still sits on top, so a developer's explicit choice is never silently overridden.

  3. generate_twiml(websocket_url=None, options=None) — positional URL now optional, falls back to options.websocket_url. Lets a channel-less edge caller (e.g. AWS AgentCore's Lambda) pass everything in one object. Raises ValueError if neither supplies a URL.

Backward compatibility

Fully additive. TACFastAPIServer and existing callers are unaffected (no host layer; unset websocket_url derives from TACConfig as before).

Tests

make check: ruff, mypy strict (75 files), 760 tests. New: host_twiml_options layering + precedence (dev beats host on contested fields, host's uncontested websocket_url still applies); websocket_url/action_url layering; generate_twiml options-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_url from TwiMLOptions — deliberate, and safe via the None default. Alternative considered: a dedicated server-URLs struct (VoiceServerURLs) — flagged so the API-shape call is explicit. Named host_ not server_ to avoid colliding with TACFastAPIServer (which notably does not pass this — it's for custom in-process hosts).

🤖 Generated with Claude Code

…_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>
Copilot AI review requested due to automatic review settings June 30, 2026 22:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_call with a keyword-only websocket_url: str | None = None parameter 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>
@ryanrouleau ryanrouleau changed the title feat(voice): allow per-call websocket_url override on handle_incoming_call feat(voice): per-call websocket_url override via layered TwiMLOptions field Jul 1, 2026
ryanrouleau and others added 4 commits July 2, 2026 11:59
… 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>
@ryanrouleau ryanrouleau changed the title feat(voice): per-call websocket_url override via layered TwiMLOptions field feat(voice): per-call TwiML overrides for custom hosts (host_twiml_options + websocket_url) Jul 2, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ryanrishi ryanrishi requested a review from Copilot July 2, 2026 20:58

Copilot AI left a comment

Copy link
Copy Markdown

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 5 out of 6 changed files in this pull request and generated 4 comments.

Comment thread src/tac/models/voice.py
Comment thread src/tac/channels/voice/twiml.py Outdated
Comment on lines +45 to +48
# 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.
Comment thread src/tac/channels/voice/twiml.py Outdated
Comment thread src/tac/channels/voice/channel.py Outdated
@@ -156,6 +156,8 @@ def _get_twilio_client(self) -> Client:
async def handle_incoming_call(

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.

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?

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.

They're two complimentary but different args:

  • twiml_request is the server agnostic representation of the initial Twiml request from Twilio, handle_incoming_call then generates the twiml response internally
  • host_twiml_options allows 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

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.

updated the docstring to make this clearer

ryanrouleau and others added 2 commits July 2, 2026 17:54
…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>
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.

4 participants