Skip to content

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:

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 str — messaging channels send a single complete message via the Conversation Orchestrator Send API and do not support streaming (unlike the Voice channel).

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 pydantic-field

dedup_capacity: int = 10000

Maximum number of idempotency tokens to track for deduplication

memory_mode pydantic-field

memory_mode: MemoryMode = 'never'

Memory retrieval mode for this channel

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:

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:

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 VoiceProviderConfig instance for any provider, or a dict. The dict form is shorthand for ConversationRelayProviderConfig (the default ConversationRelay provider) specifically, not a generic constructor — it's hydrated as ConversationRelayProviderConfig(**config) and fails if it has fields that config doesn't have. To configure a different provider, construct that provider's config and pass it directly instead of a dict. If None, uses ConversationRelayProviderConfig().

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 ConversationSession.call_sid).

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 InitiateVoiceConversationResult.call_sid or a call event.

required

Returns:

Type Description
ConversationSession | None

The session, or None — not created yet (relay-only mode, 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:

dedup_capacity pydantic-field

dedup_capacity: int = 10000

Maximum number of idempotency tokens to track for deduplication