WhatsApp Messaging

View as Markdown

This guide shows how to send and receive WhatsApp messages with the AgentDuet SDK. Voice stays on Call. Inbound WhatsApp arrives on SessionManager. Outbound send stays on Session. One subscriber can speak and chat in the same conversation.

WhatsApp must be configured on your connector. If it is not, session.send_message() returns a SendMessageResult with success=False and error_code CHANNEL_NOT_CONFIGURED.

What this channel provides

  • Inbound WhatsApp at the connector via @sm.on_incoming_message
  • Outbound WhatsApp via session.send_message(SendWAMessage(...))
  • Addressing with Address.whatsapp(value)
  • Raw Meta webhook payloads so text, buttons, and media stay in application code

How the pieces fit

LayerOwns
AgentDuetConnector delivery, session, outbound send
Your applicationPayload inspection, reply body, correlation
Meta / WhatsAppMessage types and Cloud API request shape

Inbound notifications carry addressing only plus the raw webhook payload. Correlate by (subscriber, participant) yourself. Delivery is at-least-once: dedup on IncomingMessage.id.

Prerequisites

  • Python 3.12+ and pip install agentduet==1.0.0
  • AgentDuet API key and connector UUID from agentduet.com
  • WhatsApp enabled on that connector

Receive and reply

Register @sm.on_incoming_message on the SessionManager (same process as calls). Open a session for msg.subscriber, then send. The server infers the recipient from the to field in the payload.

1from agentduet import IncomingMessage, SendWAMessage, new_session_id
2
3
4@sm.on_incoming_message
5async def on_message(msg: IncomingMessage):
6 logger.info("Message from %s: %s", msg.participant, msg.payload)
7
8 session = await sm.open_session(new_session_id(), msg.subscriber)
9 result = await session.send_message(
10 SendWAMessage(
11 api_version="v23.0",
12 data={
13 "messaging_product": "whatsapp",
14 "type": "text",
15 "to": msg.participant.value,
16 "text": {"body": "Thanks, we got your message!"},
17 },
18 )
19 )
20 if not result.success:
21 logger.error(
22 "Send failed: %s (%s)", result.error_code, result.error_content
23 )

msg.payload is the raw WhatsApp webhook body. Its shape depends on the message type (text, button, image, and so on). Inspect it before you reply.

To keep replies to the same customer on one session (and stay under the participant cap), cache a session_id per (subscriber, participant) instead of minting a new id on every message.

Start an outbound conversation

Inbound is not required to send. Open a session for your subscriber (the business WhatsApp identity) and put the customer in the payload to. Replies still arrive on @sm.on_incoming_message.

1session = await sm.open_session(new_session_id(), subscriber)
2result = await session.send_message(
3 SendWAMessage(
4 api_version="v23.0",
5 data={
6 "messaging_product": "whatsapp",
7 "type": "text",
8 "to": peer, # customer number
9 "text": {"body": "Hello from AgentDuet."},
10 },
11 )
12)
13if not result.success:
14 logger.error(
15 "Send failed: %s (%s)", result.error_code, result.error_content
16 )

subscriber is your connector’s WhatsApp identity, not the customer’s.

Addressing

1from agentduet import Address
2
3phone = Address.telco("+15551234567")
4wa = Address.whatsapp("15551234567")

IncomingMessage.participant is already an Address on the WA network. Use msg.participant.value as to when you reply.

Same session as a call

A Session can carry a call and messages for the same subscriber. Typical pattern: handle the live call on Call, then send a WhatsApp follow-up on that session after hangup (confirmation, summary, or a link).

Reuse the same session_id when you want one conversation. Use a new id when you want a fresh thread.

Routing

By default the server delivers inbound messages. Turn delivery off (or back on) with trigger conditions:

1from agentduet import InboundCallMode, TriggerConditionsBuilder
2
3await sm.setup_trigger_conditions(
4 TriggerConditionsBuilder()
5 .inbound_call(InboundCallMode.ALL)
6 .inbound_message(True) # False stops WhatsApp delivery
7 .build()
8)

Call and message routing are independent. You can take calls only, messages only, or both. The config you send is an absolute replace. See Trigger Conditions.

If you use VoiceAgent, inbound= rewrites trigger conditions on startup and resets message-flow toggles to their defaults. Pass inbound=None to leave the connector routing untouched.

Send results

send_message() returns a SendMessageResult. It does not raise for provider or quota failures. Check result.success. Do not use if result:; unlike CommandResult, SendMessageResult is not bool-like.

MessageErrorCodeMeaning
QUOTA_EXCEEDEDMessage sending limit exceeded for this connector
CHANNEL_NOT_CONFIGUREDThe session channel is not configured for messaging
REMOTE_ERRORThe provider (for example, Meta/WhatsApp) rejected the message
INVALID_REQUESTRequest payload format or structure is invalid
SESSION_BUSYThe session is bound to another live connection
SESSION_NOT_FOUNDThe session id does not exist on the server
SESSION_CLOSEDThe session has already closed
SESSION_ALREADY_EXISTSA session with that id already exists
SUBSCRIBER_MISMATCHThe subscriber does not match the session
PARTICIPANTS_FULLThe session already holds the maximum participants
CALL_NOT_FOUNDNo pending call for the given id
UNKNOWNUnrecognized code (forward-compatible fallback)

Unsupported message types passed to send_message() raise MessageError. Server-side send failures stay on SendMessageResult.