> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.agentduet.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.agentduet.com/_mcp/server.

# WhatsApp Messaging

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

```mermaid
flowchart LR
  WA[WhatsApp] --> SM[SessionManager]
  SM -->|on_incoming_message| Msg[IncomingMessage]
  Msg -->|open_session| Sess[Session]
  Sess -->|send_message| Reply[SendWAMessage]
  Reply --> WA
```

| Layer | Owns |
|---|---|
| **AgentDuet** | Connector delivery, session, outbound send |
| **Your application** | Payload inspection, reply body, correlation |
| **Meta / WhatsApp** | Message 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](https://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.

```python
from agentduet import IncomingMessage, SendWAMessage, new_session_id


@sm.on_incoming_message
async def on_message(msg: IncomingMessage):
    logger.info("Message from %s: %s", msg.participant, msg.payload)

    session = await sm.open_session(new_session_id(), msg.subscriber)
    result = await session.send_message(
        SendWAMessage(
            api_version="v23.0",
            data={
                "messaging_product": "whatsapp",
                "type": "text",
                "to": msg.participant.value,
                "text": {"body": "Thanks, we got your message!"},
            },
        )
    )
    if not result.success:
        logger.error(
            "Send failed: %s (%s)", result.error_code, result.error_content
        )
```

`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`.

```python
session = await sm.open_session(new_session_id(), subscriber)
result = await session.send_message(
    SendWAMessage(
        api_version="v23.0",
        data={
            "messaging_product": "whatsapp",
            "type": "text",
            "to": peer,  # customer number
            "text": {"body": "Hello from AgentDuet."},
        },
    )
)
if not result.success:
    logger.error(
        "Send failed: %s (%s)", result.error_code, result.error_content
    )
```

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

## Addressing

```python
from agentduet import Address

phone = Address.telco("+15551234567")
wa = 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:

```python
from agentduet import InboundCallMode, TriggerConditionsBuilder

await sm.setup_trigger_conditions(
    TriggerConditionsBuilder()
    .inbound_call(InboundCallMode.ALL)
    .inbound_message(True)   # False stops WhatsApp delivery
    .build()
)
```

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](/concepts/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.

| `MessageErrorCode` | Meaning |
|---|---|
| `QUOTA_EXCEEDED` | Message sending limit exceeded for this connector |
| `CHANNEL_NOT_CONFIGURED` | The session channel is not configured for messaging |
| `REMOTE_ERROR` | The provider (for example, Meta/WhatsApp) rejected the message |
| `INVALID_REQUEST` | Request payload format or structure is invalid |
| `SESSION_BUSY` | The session is bound to another live connection |
| `SESSION_NOT_FOUND` | The session id does not exist on the server |
| `SESSION_CLOSED` | The session has already closed |
| `SESSION_ALREADY_EXISTS` | A session with that id already exists |
| `SUBSCRIBER_MISMATCH` | The subscriber does not match the session |
| `PARTICIPANTS_FULL` | The session already holds the maximum participants |
| `CALL_NOT_FOUND` | No pending call for the given id |
| `UNKNOWN` | Unrecognized code (forward-compatible fallback) |

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

## Related

- [Quick Start](/introduction/quick-start)
- [Architecture](/concepts/architecture)
- [Sessions and Handoff](/concepts/sessions-and-handoff)
- [Trigger Conditions](/concepts/trigger-conditions)
- [Error Handling](/reference/error-handling)