# @copilotkit/channels-whatsapp

The **WhatsApp `PlatformAdapter`** for [`@copilotkit/channels`](../channels). It connects a
WhatsApp Business number to any AG-UI agent: ingress via the Meta Cloud API webhook,
egress as text or interactive messages rendered from the `@copilotkit/channels-ui` JSX
vocabulary, opaque-id interactions, and HITL.

You write your UI as JSX once (`@copilotkit/channels-ui`) and drive the bot with
`@copilotkit/channels`; this package is the only one that talks to the WhatsApp Cloud API.

The adapter keeps its own WhatsApp Cloud API credentials (`accessToken` /
`phoneNumberId` / …) — in the managed path the Channel runs inside a CopilotKit
Intelligence-configured `CopilotRuntime` (free plan available), which starts and
owns the channel's lifecycle. Building and operating your own channel runner on
the SDK primitives is also a supported path.

## Install

```sh
pnpm add @copilotkit/channels @copilotkit/channels-whatsapp
```

## Quickstart

```ts
import { createChannel } from "@copilotkit/channels";
import {
  whatsapp,
  defaultWhatsAppContext,
} from "@copilotkit/channels-whatsapp";
import { CopilotRuntime, CopilotKitIntelligence } from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";

const bot = createChannel({
  identifyUser: "platform",
  name: "support-bot", // project-unique Intelligence Channel name
  adapters: [
    whatsapp({
      accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
      phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID!,
      appSecret: process.env.WHATSAPP_APP_SECRET!,
      verifyToken: process.env.WHATSAPP_VERIFY_TOKEN!,
      port: 3000,
    }),
  ],
  agent: makeAgent(process.env.AGENT_URL!),
  tools: [...appTools],
  context: [...defaultWhatsAppContext, ...appContext],
});

// Every inbound text is for the bot — there is no @-mention concept on WhatsApp.
bot.onMessage(async ({ thread }) => {
  await thread.runAgent();
});

// The runtime owns the channel's lifecycle — there is no `bot.start()`.
const runtime = new CopilotRuntime({
  intelligence: new CopilotKitIntelligence({
    // apiUrl and wsUrl default to cloud-hosted CopilotKit Intelligence — override
    // both together only for a self-hosted deployment.
    apiKey: process.env.CPK_INTELLIGENCE_API_KEY!, // free tier available
  }),
  channels: [bot],
});

// Creating the listener starts the Channel's connection.
const listener = createCopilotNodeListener({ runtime });
// Optional: await that activation so a broken config fails startup loudly.
await listener.channels.ready(); // listener.channels.stop() tears it down
console.log("[whatsapp-bot] listening for webhooks");
```

`whatsapp(opts)` returns a `WhatsAppAdapter`. It starts an HTTP server on `port`
(default 3000) that handles the Meta webhook: a `GET /webhook` verification
handshake and signed `POST /webhook` event delivery. You must expose this port
publicly (e.g. via ngrok) and register the URL + `verifyToken` in the Meta app
configuration. See [`examples/whatsapp`](../../examples/whatsapp) for a complete
setup walkthrough.

### Required env

| Var                        | Purpose                                                     |
| -------------------------- | ----------------------------------------------------------- |
| `WHATSAPP_ACCESS_TOKEN`    | Cloud API access token (Bearer), from Meta App → API setup. |
| `WHATSAPP_PHONE_NUMBER_ID` | Business phone-number id that sends messages.               |
| `WHATSAPP_APP_SECRET`      | App secret for `X-Hub-Signature-256` webhook validation.    |
| `WHATSAPP_VERIFY_TOKEN`    | Token echoed during the GET verification handshake.         |

## Capabilities

| Capability          | Supported | Notes                                                          |
| ------------------- | --------- | -------------------------------------------------------------- |
| `supportsStreaming` | false     | WhatsApp messages are immutable; there is no edit-message API. |
| `supportsModals`    | false     | No modal surface in the Cloud API.                             |
| `supportsTyping`    | false     | No typing-indicator API for business accounts.                 |
| `supportsReactions` | false     | No reaction API for business-sent messages.                    |

Because messages are immutable, `thread.stream(...)` buffers the full iterable
and sends it as a single message — there is no token-by-token streaming. Calls to
`update` and `delete` are also no-ops (they post a new message instead, or silently
drop). The `defaultWhatsAppContext` entry tells the agent about this constraint so
it doesn't promise to "update this message."

## `WhatsAppAdapterOptions` reference

| Option                | Type                  | Default                        | Description                                                         |
| --------------------- | --------------------- | ------------------------------ | ------------------------------------------------------------------- |
| `accessToken`         | `string`              | required                       | Cloud API access token (Bearer).                                    |
| `phoneNumberId`       | `string`              | required                       | Business phone-number id that sends messages.                       |
| `appSecret`           | `string`              | required                       | App secret for `X-Hub-Signature-256` webhook validation.            |
| `verifyToken`         | `string`              | required                       | Token echoed during the GET verification handshake.                 |
| `port`                | `number`              | `3000`                         | HTTP server port.                                                   |
| `path`                | `string`              | `"/webhook"`                   | Webhook path.                                                       |
| `apiVersion`          | `string`              | `"v21.0"`                      | Graph API version.                                                  |
| `graphBaseUrl`        | `string`              | `"https://graph.facebook.com"` | Graph API base origin. Overridable for tests.                       |
| `interruptEventNames` | `ReadonlySet<string>` | `undefined`                    | Custom AG-UI event names treated as interrupts by the run renderer. |
| `commandPrefix`       | `string`              | `"/"`                          | Prefix for leading-keyword command matching.                        |
| `historyStore`        | `HistoryStore`        | `new InMemoryHistoryStore()`   | Pluggable conversation-history persistence.                         |
| `files`               | `FileDeliveryConfig`  | `{}`                           | Inbound media handling configuration.                               |

## JSX → WhatsApp rendering

`renderWhatsAppMessage(ir)` lowers the `@copilotkit/channels-ui` IR to Cloud API
payloads. The strategy:

- **0 actions** → plain `text` message (markdown converted to WhatsApp formatting).
- **1–3 button actions** → interactive `button` message (reply buttons).
- **4–10 actions** → interactive `list` message (list picker).
- **>10 actions** → numbered text menu (degraded fallback).

Image nodes always emit their own `image` payload. Markdown is translated to
WhatsApp formatting: `**bold**`, `_italic_`, `~~strikethrough~~`, `` `code` ``, and
code blocks. Headings, tables, and clickable Markdown links are not supported on
WhatsApp — links render as plain text.

### Per-element budget

WhatsApp caps interactive elements. Limits live in `WA_LIMITS`:

| Limit               | Value | Element                                          |
| ------------------- | ----- | ------------------------------------------------ |
| `bodyText`          | 4096  | text message body chars                          |
| `replyButtons`      | 3     | reply buttons in an interactive button message   |
| `buttonTitle`       | 20    | reply-button title chars                         |
| `interactiveBody`   | 1024  | interactive message body chars                   |
| `interactiveHeader` | 60    | interactive header chars                         |
| `interactiveFooter` | 60    | interactive footer chars                         |
| `listRows`          | 10    | total rows across all sections in a list message |
| `rowTitle`          | 24    | list-row title chars                             |
| `rowDescription`    | 72    | list-row description chars                       |
| `listButton`        | 20    | list open-button label chars                     |
| `controlId`         | 256   | interactive control id chars                     |

## Persistence

### ActionStore (interaction rehydration)

The engine's `ActionStore` (from `@copilotkit/channels`) stores the minted opaque ids
that power `Button` / `Select` click handlers. By default it is in-memory: after a
process restart, clicks on old interactive messages are acknowledged but ignored.
For persistent interactions, pass a durable `ActionStore` to
`createChannel({ actionStore })`.

### HistoryStore (conversation memory)

Unlike Slack, WhatsApp exposes no readable message history. The adapter maintains
its own `HistoryStore` and replays it into `agent.messages` on every turn. The
default is `InMemoryHistoryStore` (up to 100 messages per conversation, drops
oldest). Swap a durable backend by implementing the `HistoryStore` interface:

```ts
interface HistoryStore {
  append(conversationKey: string, message: StoredMessage): Promise<void>;
  read(conversationKey: string): Promise<StoredMessage[]>;
}
```

Pass it as `historyStore` in the adapter options:

```ts
whatsapp({
  // ...
  historyStore: new MyRedisHistoryStore(),
});
```

Without a durable `HistoryStore`, conversation history is lost on process restart.

## Commands

Commands are matched by a leading keyword in the message text (default prefix `/`).
Register handlers with `bot.onCommand`:

```ts
bot.onCommand("status", async ({ thread, text }) => {
  await thread.runAgent({ prompt: `Status check: ${text}` });
});
```

Unlike Slack, WhatsApp has no native slash-command surface — commands are plain
text messages that start with the prefix. They are NOT pre-filtered by the adapter
(the engine matches them), and command messages are not persisted to the
`HistoryStore` at ingress. Sent commands need to be serialized into the agent prompt
explicitly if the agent needs to see them as history.

## Built-ins

- `defaultWhatsAppTools` — empty in v1 (WhatsApp exposes no user directory, so
  there is no `lookup_user` equivalent). Spread into `tools` for future
  compatibility.
- `defaultWhatsAppContext` — two context entries: WhatsApp formatting rules
  (bold/italic/code, no headings or clickable links) and delivery constraints (no
  streaming, no message editing). Spread into `context`.
- `whatsAppFormattingContext` / `whatsAppDeliveryContext` — the individual entries
  if you need to compose them selectively.

## Tool context

Tools receive the single shared `ChannelToolContext` from `@copilotkit/channels`
(`{ thread, message?, user?, signal?, platform }`) and reach WhatsApp power through
capability-gated `thread` methods this adapter backs:

- `thread.getMessages()` — the current conversation's message history (from
  `HistoryStore`), each a `ThreadMessage` (`{ user?, text, ts?, isBot? }`).
- `thread.postFile({ bytes, filename, title?, altText? })` — upload and send a
  file (image → `image` payload; other → `document` payload via the media-upload
  API).

Note: `thread.lookupUser(query)` is a no-op on WhatsApp — the Cloud API exposes no
user directory. It always returns `undefined`.

## Running the demo

This package is the **library**. A runnable end-to-end demo wiring everything
against a real WhatsApp number lives in
[`examples/whatsapp`](../../examples/whatsapp).

## What's NOT in v1

- No message editing or streaming (WhatsApp messages are immutable)
- No proactive messaging outside the 24-hour customer-service window — the adapter
  does not implement template-message sending; the bot can only reply within the
  24-hour window opened by an inbound user message
- No user directory (`lookupUser` always returns `undefined`)
- No OAuth / multi-number install (single access token only)
- Durable `ActionStore` and `HistoryStore` are in-memory by default; actions and
  history expire on restart unless you provide durable implementations

## Exports

`whatsapp`, `WhatsAppAdapter`; `WhatsAppAdapterOptions`, `ReplyTarget`,
`WhatsAppMessageRef` (types); `WhatsAppConversationStore`;
`InMemoryHistoryStore`, `HistoryStore`, `StoredMessage` (types);
`renderWhatsAppMessage`, `WhatsAppOutbound` (type); `WA_LIMITS`, `truncateText`,
`clampArray`; `markdownToWhatsApp`; `decodeInteraction`, `conversationKeyOf`;
`createRunRenderer`; `WhatsAppClient`, `DownloadedMedia` (type);
`buildFileContentParts`, `AgentContentPart`, `FileDeliveryConfig` (types);
`defaultWhatsAppTools`; `defaultWhatsAppContext`, `whatsAppFormattingContext`,
`whatsAppDeliveryContext`.
