Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2b240e3
feat(voice): expose full TwiML customization via resolver + widened o…
ryanrouleau May 14, 2026
23f67a6
refactor(voice): static twiml_options layer, rename resolver → custom…
ryanrouleau May 14, 2026
7addb9e
refactor(voice): extract VoiceServerURLs; drop websocket_url from Twi…
ryanrouleau May 14, 2026
4f886d1
refactor(server): drop defensive try/except around request.form() in …
ryanrouleau May 14, 2026
d2d2572
refactor(voice): rename VoiceServerURLs.conversation_relay_callback_u…
ryanrouleau May 14, 2026
0ae56f9
refactor(voice): flip action_url precedence — user intent beats SDK d…
ryanrouleau May 14, 2026
6e601cf
refactor(voice): rename TwiMLRequestContext → TwiMLRequest, request_c…
ryanrouleau May 14, 2026
0300a56
docs(examples): show the three TwiML customization layers in one file
ryanrouleau May 14, 2026
7f8d4b0
docs(examples): rename twiml_customization.py → voice_twiml_customiza…
ryanrouleau May 14, 2026
7eab73a
refactor(voice): rename VoiceServerURLs → VoiceEndpoints, server_urls…
ryanrouleau May 14, 2026
787c8ec
feat(voice): add TwiMLOptions.extra escape hatch for newly-added Conv…
ryanrouleau May 14, 2026
044d402
refactor(voice): remove VoiceChannelConfig.welcome_greeting shortcut
ryanrouleau May 14, 2026
47f0265
feat(voice): full ConversationRelay attribute coverage on TwiMLOptions
ryanrouleau May 14, 2026
ba021a5
refactor(voice): address review feedback — forbid unknown kwargs, cla…
ryanrouleau May 14, 2026
40b16ab
feat(voice): outbound calls honor VoiceChannelConfig.twiml_options
ryanrouleau May 14, 2026
a937b8b
docs(voice): clarify per-field merge semantics on outbound twiml_options
ryanrouleau May 14, 2026
fc75195
docs(examples): call out per-call vs channel-wide TwiML customization
ryanrouleau May 14, 2026
f078b96
docs(examples): trim outbound TwiML comment — point at inbound equiva…
ryanrouleau May 14, 2026
93e3ce3
refactor(voice): centralize TwiML config on VoiceChannelConfig; renam…
ryanrouleau May 18, 2026
16b16d4
docs(voice): address review feedback — stale names, exports, deprecat…
ryanrouleau May 18, 2026
779c437
fix(voice): normalize bool interruptible; doc edge cases; guard attr …
ryanrouleau May 18, 2026
547ef84
fix(server): require Twilio signature on /conversation-relay-callback
ryanrouleau May 18, 2026
0fd34f2
fix(voice): address remaining inline review comments
ryanrouleau May 18, 2026
99c9046
refactor(voice): simplify deprecation forwarding, extract merge helper
ryanrouleau May 18, 2026
c53bbea
docs(examples): teach the layering, drop redundant URL plumbing
ryanrouleau May 18, 2026
d7eb8ca
refactor(voice): centralize voice URLs on TACConfig + register custom…
ryanrouleau May 18, 2026
1bef7f5
refactor(voice): remove deprecation plumbing for old voice config fields
ryanrouleau May 19, 2026
8c80073
fix(voice): tighten URL config — suppression, normalization, fail-fast
ryanrouleau May 19, 2026
27ff56d
docs(examples): hint at TwiMLOptions.extra escape hatch
ryanrouleau May 19, 2026
1e0f97c
refactor(voice): centralize voice URL paths on TACConfig, drop overrides
ryanrouleau May 21, 2026
53bf02e
refactor(voice): trim post-iteration cruft and forbid extras on outbo…
ryanrouleau May 21, 2026
a807509
refactor(voice): tighten TwiMLOptions surface and trim test cruft
ryanrouleau May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions getting_started/examples/features/outbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

from tac.models.session import ConversationSession
from tac.models.tac import TACMemoryResponse
from tac.models.voice import TwiMLOptions
from tac.server import TACFastAPIServer
from tac.server.config import TACServerConfig

load_dotenv()
set_tracing_disabled(True)
Expand Down Expand Up @@ -91,7 +91,17 @@ def parse_args() -> argparse.Namespace:
if tac.config.whatsapp_number
else None
)
voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(memory_mode="always"))
voice_channel = VoiceChannel(
tac,
config=VoiceChannelConfig(
memory_mode="once",
# Channel-wide TwiML applied to every call (inbound + outbound).
default_twiml_options=TwiMLOptions(
voice="en-US-Journey-D",
welcome_greeting="Hi! How can I help?",
),
),
)


async def handle_message_ready(
Expand Down Expand Up @@ -162,18 +172,11 @@ async def initiate_outbound(args: argparse.Namespace) -> None:
print("\nWaiting for replies... (Ctrl+C to exit)\n")

elif args.channel == "voice":
server_config = TACServerConfig.from_env()
public_domain = server_config.public_domain
if not public_domain:
print("TWILIO_VOICE_PUBLIC_DOMAIN is required for voice calls.")
sys.exit(1)

voice_result = await voice_channel.initiate_outbound_conversation(
InitiateVoiceConversationOptions(
to=args.to,
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

twiml_options=TwiMLOptions(welcome_greeting=args.welcome_greeting),
)
)
print(f"Call placed to {args.to} (SID: {voice_result.call_sid})")
Expand Down
82 changes: 82 additions & 0 deletions getting_started/examples/features/voice_twiml_customization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""
Feature: ConversationRelay TwiML customization

Two layers (highest precedence first):

1. Per-call customizer — register on the channel via
``voice_channel.on_inbound_call_twiml(...)``. Async callable that
receives a TwiMLRequest (parsed Twilio webhook fields: From, To,
CallerCountry, …). Inbound only. For outbound, pass per-call
TwiMLOptions on InitiateVoiceConversationOptions.
2. ``VoiceChannelConfig.default_twiml_options`` — static TwiMLOptions
applied to every call (inbound and outbound).

Layers merge per-field: the customizer overrides only the fields it
explicitly sets; everything else falls through to ``default_twiml_options``
and then to TAC defaults (websocket URL, action URL, conversation_configuration).

This example shows both layers together — channel-wide defaults plus a
country-based customizer that overrides language/voice/greeting for
specific callers.
"""

from dotenv import load_dotenv

from tac import TAC, TACConfig
from tac.channels.voice import VoiceChannel, VoiceChannelConfig
from tac.models.session import ConversationSession
from tac.models.tac import TACMemoryResponse
from tac.models.voice import TwiMLOptions, TwiMLRequest
from tac.server import TACFastAPIServer

load_dotenv()

tac = TAC(config=TACConfig.from_env())


async def handle_message_ready(
user_message: str,
context: ConversationSession,
memory_response: TACMemoryResponse | None,
) -> str:
return f"You said: {user_message}"


tac.on_message_ready(handle_message_ready)


async def customize_twiml(req: TwiMLRequest) -> TwiMLOptions:
"""Per-call overrides for inbound calls. Only the fields you set here
override the channel default; the rest fall through."""
if req.caller_country == "MX":
return TwiMLOptions(
language="es-MX",
welcome_greeting="¡Hola! ¿En qué puedo ayudarte?",
)
if req.caller_country == "FR":
return TwiMLOptions(
language="fr-FR",
welcome_greeting="Bonjour ! Comment puis-je vous aider ?",
)
return TwiMLOptions() # fall through to default_twiml_options


voice_channel = VoiceChannel(
tac,
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

interruptible="speech",
# Escape hatch for ConversationRelay attributes TAC hasn't typed yet:
# extra={"newRelayAttribute": "value"},
),
),
)
# 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



if __name__ == "__main__":
server = TACFastAPIServer(tac=tac, voice_channel=voice_channel)
server.start()
19 changes: 17 additions & 2 deletions src/tac/channels/voice/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
"""Voice channel for handling voice-based conversations."""

from tac.channels.voice.channel import VoiceChannel
from tac.channels.voice.config import VoiceChannelConfig
from tac.channels.voice.config import InboundCallTwiMLHandler, VoiceChannelConfig
from tac.channels.voice.twiml import generate_twiml
from tac.models.voice import (
InterruptMode,
LanguageConfig,
TwiMLOptions,
TwiMLRequest,
)

__all__ = ["VoiceChannel", "VoiceChannelConfig", "generate_twiml"]
__all__ = [
"InboundCallTwiMLHandler",
"InterruptMode",
"LanguageConfig",
"TwiMLOptions",
"TwiMLRequest",
"VoiceChannel",
"VoiceChannelConfig",
"generate_twiml",
]
Loading