Channels¶
tac.channels ¶
Communication channels for the Twilio Agent Connect.
BaseChannel ¶
BaseChannel(
tac: TAC,
memory_mode: MemoryMode = "never",
dedup_capacity: int = 10000,
)
Bases: ABC
Abstract base class for TAC channels.
Channels handle protocol-specific webhook processing and response delivery for different communication channels (SMS, Voice, etc.).
This class provides common conversation lifecycle management that is shared across all channel types.
Initialize base channel.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tac
|
TAC
|
TAC instance for memory/context operations |
required |
memory_mode
|
MemoryMode
|
Memory retrieval mode. Default is "never". - "always": Retrieve memory for every message with the query string - "once": Retrieve memory once at conversation start with empty query and cache it. Cache is invalidated when conversation becomes INACTIVE. - "never": Skip memory retrieval |
'never'
|
dedup_capacity
|
int
|
Maximum number of idempotency tokens to track for webhook deduplication. Default 10000. Must be positive. |
10000
|
process_webhook
abstractmethod
async
¶
process_webhook(
webhook_data: dict[str, Any],
idempotency_token: str | None = None,
) -> None
Process incoming webhook event from Twilio.
This method should: 1. Parse and validate webhook data 2. Handle conversation lifecycle (start, message, end) 3. Trigger memory retrieval via TAC 4. Invoke registered callbacks
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
webhook_data
|
dict[str, Any]
|
Raw webhook event data from Twilio |
required |
idempotency_token
|
str | None
|
Optional Twilio idempotency token from request headers |
None
|
send_response
abstractmethod
async
¶
send_response(
conversation_id: str,
response: str
| AsyncGenerator[str | dict[str, Any], None],
role: str | None = None,
) -> None
Send response back through the channel.
Supports both simple string responses and streaming via async generators.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
conversation_id
|
str
|
Conversation ID to send response to |
required |
response
|
str | AsyncGenerator[str | dict[str, Any], None]
|
Message content (string) or async generator for streaming |
required |
role
|
str | None
|
Optional message role (e.g., 'assistant', 'user', 'system') |
None
|
get_channel_name
abstractmethod
¶
get_channel_name() -> str
Get the channel name identifier.
Returns:
| Type | Description |
|---|---|
str
|
Channel name (e.g., 'SMS', 'VOICE') |
ChatChannel ¶
ChatChannel(
tac: TAC,
config: ChatChannelConfig
| dict[str, Any]
| None = None,
)
Bases: MessagingChannel
Chat Channel for handling web chat conversations.
Uses identity-based addressing instead of phone numbers. Automatically creates AI_AGENT participant if needed (lazy creation) and manages conversation lifecycle through Conversation Orchestrator webhooks.
initiate_outbound_conversation
async
¶
initiate_outbound_conversation(
options: InitiateChatConversationOptions,
) -> InitiateConversationResult
Initiate an outbound Chat conversation.
Creates a conversation via Conversation Orchestrator with inline participants, then sends the initial message via the Actions API. If an active conversation with the same addresses already exists (group-by dedup), CO returns 409 and the existing conversation is reused.
ChatChannelConfig
pydantic-model
¶
Bases: MessagingChannelConfig
Configuration for Chat channel.
Attributes:
| Name | Type | Description |
|---|---|---|
agent_address |
str
|
Chat agent identity string used to identify the bot's messages. |
Fields:
-
dedup_capacity(int) -
memory_mode(MemoryMode) -
agent_address(str)
agent_address
pydantic-field
¶
agent_address: str = 'ai-assistant'
Chat agent identity string for bot message filtering
MessagingChannel ¶
MessagingChannel(
tac: TAC,
dedup_capacity: int = 10000,
memory_mode: MemoryMode = "never",
)
Bases: BaseChannel
Abstract base class for messaging channels (SMS, RCS, WhatsApp, Chat).
Provides shared webhook processing logic for channels that use Conversation Orchestrator webhooks with COMMUNICATION_CREATED and CONVERSATION_UPDATED event types.
Subclasses must implement: - is_default_agent_address(): Fast-path check for the channel's default agent address - get_agent_address(conversation_id): Return the agent's ParticipantAddress for a conversation - get_channel_name(): Return channel name ("SMS", "RCS", "WHATSAPP", "CHAT")
send_response() is provided here as a shared implementation. Subclasses may override _build_channel_settings() to customize how ActionChannelSettings is built for the outbound send (e.g. chat requires channel_id).
Subclass class attributes: - reconcile_customer_type: If True, reconciliation will also promote a channel-matching UNKNOWN participant (not owning the agent address) to CUSTOMER. Set False for channels where the customer is identified author-driven (e.g. chat).
is_default_agent_address
abstractmethod
¶
is_default_agent_address(author_address: str) -> bool
Fast-path check: is the author address this channel's default agent address?
For example, config.phone_number for SMS, config.rcs_sender_id for RCS, config.whatsapp_number for WhatsApp, agent_address for Chat.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
author_address
|
str
|
The address of the message author |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the address matches the channel's default agent address |
get_agent_address
abstractmethod
¶
get_agent_address(
conversation_id: str,
) -> ParticipantAddress
Return the agent-side ParticipantAddress for this conversation.
Used by _reconcile_participants to identify which participant (by
channel + address) represents the agent. May read from session state
(e.g. chat's per-conversation channelId) to build the address.
send_response
async
¶
send_response(
conversation_id: str,
response: str
| AsyncGenerator[str | dict[str, Any], None],
role: str | None = None,
) -> None
Send a text response using the Conversation Orchestrator Send API.
Reads the agent and customer participant ids stashed on the session
by inbound reconciliation or outbound initiation. Missing ids are a
misuse — send_response is only expected to be called after an inbound
webhook (COMMUNICATION_CREATED → reconcile) or after
initiate_outbound_conversation, both of which populate the session.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
conversation_id
|
str
|
Conversation ID to send response to |
required |
response
|
str | AsyncGenerator[str | dict[str, Any], None]
|
Message content. Must be |
required |
role
|
str | None
|
Optional message role (unused by messaging channels) |
None
|
Raises:
| Type | Description |
|---|---|
TypeError
|
If response is not a string (e.g. an async generator is passed, since messaging channels don't support streaming) |
RuntimeError
|
If the session or participant ids are missing |
process_webhook
async
¶
process_webhook(
webhook_data: dict[str, Any],
idempotency_token: str | None = None,
) -> None
Process messaging channel webhook event and manage conversation lifecycle.
Handles: - COMMUNICATION_CREATED: Process incoming messages from customers - CONVERSATION_UPDATED: Clean up when conversation is closed
Note: Conversation tracking uses instance-local memory. In multi-instance deployments, webhooks may route to a different instance, preventing cleanup. See CLAUDE.md for horizontal scaling considerations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
webhook_data
|
dict[str, Any]
|
Raw webhook event data from Twilio |
required |
idempotency_token
|
str | None
|
Optional Twilio idempotency token from request headers |
None
|
MessagingChannelConfig
pydantic-model
¶
Bases: BaseModel
Base configuration for messaging channels (SMS, RCS, WhatsApp, Chat).
Attributes:
| Name | Type | Description |
|---|---|---|
dedup_capacity |
int
|
Maximum number of idempotency tokens to track. Default 10000 is suitable for most applications. Uses Twilio's i-twilio-idempotency-token header for deduplication. |
memory_mode |
MemoryMode
|
Memory retrieval mode. Default is "never". - "always": Retrieve memory for every message with the query string - "once": Retrieve memory once at conversation start with empty query and cache it. Cache is invalidated when conversation becomes INACTIVE and is fetched again the next time a message triggers memory retrieval after the conversation becomes ACTIVE. - "never": Skip memory retrieval |
Fields:
-
dedup_capacity(int) -
memory_mode(MemoryMode)
RCSChannel ¶
RCSChannel(
tac: TAC,
config: RCSChannelConfig | dict[str, Any] | None = None,
)
Bases: MessagingChannel
RCS Channel for handling RCS-based conversations.
Inherits shared messaging channel webhook processing from MessagingChannel and provides RCS-specific message sending and filtering.
RCS uses RCS Sender IDs configured in TACConfig (via TWILIO_RCS_SENDER_ID).
is_default_agent_address ¶
is_default_agent_address(author_address: str) -> bool
Check if the author address matches the configured RCS sender ID.
get_agent_address ¶
get_agent_address(
conversation_id: str,
) -> ParticipantAddress
Get the agent's participant address for this conversation.
initiate_outbound_conversation
async
¶
initiate_outbound_conversation(
options: InitiateMessagingConversationOptions,
) -> InitiateConversationResult
Initiate an outbound RCS conversation.
Creates a conversation via Conversation Orchestrator with inline participants, then sends the initial message via the Actions API. Uses the RCS sender ID from TACConfig as the from address. If an active conversation with the same addresses already exists (group-by dedup), CO returns 409 and the existing conversation is reused.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
options
|
InitiateMessagingConversationOptions
|
Conversation initiation options (to address and message) |
required |
Returns:
| Type | Description |
|---|---|
InitiateConversationResult
|
InitiateConversationResult with conversation_id and session |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If rcs_sender_id is not configured |
RCSChannelConfig
pydantic-model
¶
Bases: MessagingChannelConfig
Configuration for RCS channel.
Inherits dedup_capacity and memory_mode from MessagingChannelConfig.
Fields:
-
memory_mode(MemoryMode) -
dedup_capacity(int)
dedup_capacity
pydantic-field
¶
dedup_capacity: int = 10000
Maximum number of idempotency tokens to track for deduplication
SMSChannel ¶
SMSChannel(
tac: TAC,
config: SMSChannelConfig | dict[str, Any] | None = None,
)
Bases: MessagingChannel
SMS Channel for handling SMS-based conversations.
Inherits shared messaging channel webhook processing from MessagingChannel and provides SMS-specific message sending and filtering.
initiate_outbound_conversation
async
¶
initiate_outbound_conversation(
options: InitiateMessagingConversationOptions,
) -> InitiateConversationResult
Initiate an outbound SMS conversation.
Creates a conversation via Conversation Orchestrator with inline participants, then sends the initial message via the Actions API. If an active conversation with the same addresses already exists (group-by dedup), CO returns 409 and the existing conversation is reused.
SMSChannelConfig
pydantic-model
¶
Bases: MessagingChannelConfig
Configuration for SMS channel.
Inherits dedup_capacity and memory_mode from MessagingChannelConfig.
Fields:
-
memory_mode(MemoryMode) -
dedup_capacity(int)
dedup_capacity
pydantic-field
¶
dedup_capacity: int = 10000
Maximum number of idempotency tokens to track for deduplication
VoiceChannel ¶
VoiceChannel(
tac: TAC,
config: VoiceProviderConfig
| dict[str, Any]
| None = None,
)
Bases: BaseChannel
Voice Channel for handling voice-based conversations via WebSocket.
Owns the Twilio Calls API lifecycle and conversation bookkeeping
(inherited from BaseChannel). The real-time media transport itself —
TwiML generation, WebSocket protocol handling, outbound call
initiation — is delegated to a pluggable VoiceProvider.
This channel is framework-agnostic and accepts any WebSocket implementation satisfying WebSocketProtocol. For a batteries-included FastAPI server, use tac.server.TACFastAPIServer.
Initialize Voice channel for websocket protocol handling.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tac
|
TAC
|
TAC instance for memory/context operations |
required |
config
|
VoiceProviderConfig | dict[str, Any] | None
|
Voice channel configuration — a |
None
|
Examples:
>>> channel = VoiceChannel(tac, config={"memory_mode": "always"})
>>> channel = VoiceChannel(
... tac, config=ConversationRelayProviderConfig(session_manager=sm)
... )
>>> channel = VoiceChannel(tac) # Use defaults
on_inbound_call_twiml ¶
on_inbound_call_twiml(
callback: InboundCallTwiMLHandler,
) -> None
Register a callback that produces per-call overrides for the active provider's inbound-call TwiML.
The callback receives a framework-neutral TwiMLRequest (parsed
from the Twilio webhook form) and returns a VoiceTwiMLOptions —
the concrete subclass the active provider expects (e.g.
VoiceTwiMLOptionsConversationRelay for the default
ConversationRelay provider; see that provider's
handle_incoming_call for its merge/precedence rules).
Outbound calls don't use this — pass per-call TwiML via
InitiateVoiceConversationOptions.twiml_options directly.
on_call_status ¶
on_call_status(callback: CallStatusHandler) -> None
Register a handler for Twilio status_callback webhooks.
This is the Calls-API status callback (call disposition), not the
active provider's own out-of-band lifecycle webhook — see
:meth:handle_twilio_provider_callback.
Registering does two things: it stores the handler, and it makes later
outbound calls pass status_callback to calls.create. With no
handler registered TAC omits that parameter, so Twilio has nowhere to
post and the event never arrives.
Twilio reports only the terminal event by default, which covers every
disposition; set CallOptions.status_callback_event for
ringing/answered.
Example
async def on_call_status(event: CallStatusEvent) -> None:
if event.is_unreached:
... # queue a retry
voice_channel.on_call_status(on_call_status)
on_amd ¶
on_amd(callback: AmdHandler) -> None
Register a handler for Twilio async_amd_status_callback webhooks.
Registering makes later outbound calls pass
async_amd_status_callback to calls.create; without a handler TAC
omits it and Twilio has nowhere to post the result. It does not enable
detection — that's per-call, via CallOptions.machine_detection and
async_amd, both of which are required for this to fire (at most once
per call).
Example
async def on_amd(event: AmdEvent) -> None:
if event.is_machine:
await voice_channel.end_call(event.call_sid) # voicemail → hang up
voice_channel.on_amd(on_amd)
on_recording ¶
on_recording(callback: RecordingHandler) -> None
Register a handler for Twilio recording_status_callback webhooks.
Registering makes later outbound calls pass
recording_status_callback to calls.create; without a handler TAC
omits it and Twilio has nowhere to post. It does not start recording —
that's CallOptions.record, which is required for this to fire.
Example
async def on_recording(event: RecordingEvent) -> None:
if event.recording_status == "completed":
... # store event.recording_url
voice_channel.on_recording(on_recording)
handle_incoming_call
async
¶
handle_incoming_call(
twiml_request: TwiMLRequest | None = None,
*,
host_twiml_options: VoiceTwiMLOptions | None = None,
) -> str
Generate TwiML response for incoming voice calls. Delegates to the
active provider — see ConversationRelayProvider.handle_incoming_call
for the full merge/precedence rules (only meaningful for that provider;
a non-TwiML provider ignores host_twiml_options).
host_twiml_options is typed against the VoiceTwiMLOptions base —
the active provider defines the concrete shape it expects (e.g.
VoiceTwiMLOptionsConversationRelay) and validates it at runtime.
handle_twilio_provider_callback
async
¶
handle_twilio_provider_callback(
payload_dict: dict[str, str],
) -> None
Handle the active provider's own out-of-band lifecycle webhook, if
it has one — e.g. ConversationRelay's <Connect action=...> callback.
In relay-only mode, this is a secondary mechanism for cleaning up conversation state when a call ends (the primary mechanism is websocket disconnect). In orchestrated mode, conversation lifecycle is managed by CO webhooks, so this is a no-op.
Not every provider has an equivalent webhook — see
VoiceProvider.handle_twilio_provider_callback.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload_dict
|
dict[str, str]
|
Raw form data dict from the webhook request. |
required |
handle_call_status_event
async
¶
handle_call_status_event(
payload_dict: dict[str, str],
) -> None
Handle a Twilio status_callback webhook.
The developer routes the request here (TACFastAPIServer does this
automatically for its /status call-event route). Parsed into a
:class:CallStatusEvent and dispatched to the :meth:on_call_status
handler. No-op if no handler is registered.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload_dict
|
dict[str, str]
|
Raw form data dict from the webhook request. |
required |
handle_amd_event
async
¶
handle_amd_event(payload_dict: dict[str, str]) -> None
Handle a Twilio async_amd_status_callback webhook.
The developer routes the request here (TACFastAPIServer does this
automatically for its /amd call-event route). Parsed into an
:class:AmdEvent and dispatched to the :meth:on_amd handler. No-op if
no handler is registered.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload_dict
|
dict[str, str]
|
Raw form data dict from the webhook request. |
required |
handle_recording_event
async
¶
handle_recording_event(
payload_dict: dict[str, str],
) -> None
Handle a Twilio recording_status_callback webhook.
The developer routes the request here (TACFastAPIServer does this
automatically for its /recording call-event route). Parsed into a
:class:RecordingEvent and dispatched to the :meth:on_recording
handler. No-op if no handler is registered.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload_dict
|
dict[str, str]
|
Raw form data dict from the webhook request. |
required |
end_call
async
¶
end_call(call_sid: str) -> bool
Hang up a call and clean up its session.
Works on call_sid alone, whether or not a session exists yet.
No-ops the session cleanup if none is tracked.
Does not raise — hanging up an already-ended call is routine (the callee hangs up while AMD is still resolving), and handlers shouldn't have to guard against it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call_sid
|
str
|
Twilio Call SID (from a call event, the outbound result, or
|
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if Twilio accepted the hangup, False if it failed (logged). |
bool
|
Session cleanup runs either way. |
get_conversation_session_by_call_sid ¶
get_conversation_session_by_call_sid(
call_sid: str,
) -> ConversationSession | None
Look up the active voice session for a Twilio Call SID.
Out-of-band code holding a CallSid — a dashboard route, an operator action, a call-event handler — can't reach the session-facing methods, which are keyed by conversation id: the Orchestrator conversation id in orchestrator mode, the CallSid only in ConversationRelay-only mode.
Relay-only mode creates the session on the caller's first prompt.
Orchestrator mode creates it earlier — as soon as the background CO
lookup started at WebSocket setup finishes — so it may already exist
before the caller has said anything, including before on_amd
fires. Either way, treat this as racy and use :meth:end_call to hang
up, which works whether or not a session exists yet.
At the other end, orchestrator mode keeps the session until Conversation
Orchestrator's CLOSED webhook, so it outlives the call and
on_call_status / on_recording do resolve. Relay-only mode tears
down on the provider's out-of-band callback instead, which races them.
Named for ConversationSession; session_manager deals in
SessionState, a different type.
Example
async def nudge(call_sid: str) -> None:
session = voice_channel.get_conversation_session_by_call_sid(call_sid)
if session is not None:
await voice_channel.send_response(session.conversation_id, "Still there?")
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call_sid
|
str
|
Twilio Call SID, e.g. from
|
required |
Returns:
| Type | Description |
|---|---|
ConversationSession | None
|
The session, or |
ConversationSession | None
|
orchestrator mode where the background CO lookup hasn't finished), |
ConversationSession | None
|
the call ended, or it landed on another instance (see the |
ConversationSession | None
|
horizontal-scaling note in CLAUDE.md). |
handle_websocket
async
¶
handle_websocket(websocket: WebSocketProtocol) -> None
Handle voice streaming WebSocket connection lifecycle. Delegates to the active provider.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
websocket
|
WebSocketProtocol
|
Any WebSocket implementation satisfying WebSocketProtocol |
required |
initiate_outbound_conversation
async
¶
initiate_outbound_conversation(
options: InitiateVoiceConversationOptions,
) -> InitiateVoiceConversationResult
Initiate an outbound voice conversation.
Only ConversationRelayProvider supports outbound calls today —
raises NotImplementedError for any other provider.
process_webhook
async
¶
process_webhook(
webhook_data: dict[str, Any],
idempotency_token: str | None = None,
) -> None
Process conversation webhooks for cleanup and cache invalidation.
Voice channel processes CONVERSATION_UPDATED events: - CLOSED status: Clean up local session state - INACTIVE status: Invalidate cached memory (memory will be updated by Conversation Orchestrator)
Note: Conversation tracking uses instance-local memory. In multi-instance deployments, webhooks may route to a different instance, preventing cleanup. See CLAUDE.md for horizontal scaling considerations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
webhook_data
|
dict[str, Any]
|
Raw webhook event data from Twilio |
required |
idempotency_token
|
str | None
|
Optional Twilio idempotency token from request headers |
None
|
send_response
async
¶
send_response(
conversation_id: str,
response: str
| AsyncGenerator[str | dict[str, Any], None],
role: str | None = None,
) -> None
Send a response back through this channel's active provider.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
conversation_id
|
str
|
Conversation ID |
required |
response
|
str | AsyncGenerator[str | dict[str, Any], None]
|
Response text (string) or async generator for streaming |
required |
role
|
str | None
|
Optional message role (not used by ConversationRelayProvider, but kept for API consistency with BaseChannel interface) |
None
|
get_websocket ¶
get_websocket(
conversation_id: str,
) -> WebSocketProtocol | None
Get the WebSocket connection for a specific conversation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
conversation_id
|
str
|
Conversation ID |
required |
Returns:
| Type | Description |
|---|---|
WebSocketProtocol | None
|
WebSocket connection if exists, None otherwise |
WhatsAppChannel ¶
WhatsAppChannel(
tac: TAC,
config: WhatsAppChannelConfig
| dict[str, Any]
| None = None,
)
Bases: MessagingChannel
WhatsApp Channel for handling WhatsApp-based conversations.
Inherits shared messaging channel webhook processing from MessagingChannel and provides WhatsApp-specific message sending and filtering.
WhatsApp uses WhatsApp sender phone numbers configured in TACConfig (via TWILIO_WHATSAPP_NUMBER). Address format: whatsapp:+1234567890
is_default_agent_address ¶
is_default_agent_address(author_address: str) -> bool
Check if the author address matches the configured WhatsApp number.
get_agent_address ¶
get_agent_address(
conversation_id: str,
) -> ParticipantAddress
Get the agent's participant address for this conversation.
initiate_outbound_conversation
async
¶
initiate_outbound_conversation(
options: InitiateMessagingConversationOptions,
) -> InitiateConversationResult
Initiate an outbound WhatsApp conversation.
Creates a conversation via Conversation Orchestrator with inline participants, then sends the initial message via the Actions API. Uses the WhatsApp number from TACConfig as the from address. If an active conversation with the same addresses already exists (group-by dedup), CO returns 409 and the existing conversation is reused.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
options
|
InitiateMessagingConversationOptions
|
Conversation initiation options (to address and message) |
required |
Returns:
| Type | Description |
|---|---|
InitiateConversationResult
|
InitiateConversationResult with conversation_id and session |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If whatsapp_number is not configured |
WhatsAppChannelConfig
pydantic-model
¶
Bases: MessagingChannelConfig
Configuration for WhatsApp channel.
Inherits dedup_capacity and memory_mode from MessagingChannelConfig.
Fields:
-
memory_mode(MemoryMode) -
dedup_capacity(int)
dedup_capacity
pydantic-field
¶
dedup_capacity: int = 10000
Maximum number of idempotency tokens to track for deduplication