showcase/shell-docs/src/content/docs/frontends/whatsapp.mdx
This guide takes you from zero to a WhatsApp bot that replies to every inbound message, then adds an interactive button card. You write handlers in TypeScript, the agent's replies are sent as WhatsApp messages, and rich UI is JSX that the adapter renders to WhatsApp interactive messages. You need a public URL (e.g. via ngrok) for the Meta webhook.
ngrok http 3000) 1. Go to [developers.facebook.com](https://developers.facebook.com) and click **My Apps → Create App**.
2. Choose **Business** type, give it a name, and create it.
3. On the dashboard, click **Set up** on **WhatsApp** under **Add products**.
4. On **WhatsApp → API Setup**, note the **Test phone number** and its **Phone number ID**.
5. Copy the **Temporary access token** — this is your `WHATSAPP_ACCESS_TOKEN`.
6. Under **App Settings → Basic**, copy the **App Secret** — this is your `WHATSAPP_APP_SECRET`.
<Callout type="info" title="Free test number">
The Meta test environment gives you a free business number immediately. No approved number is required for development.
</Callout>
</Step>
<Step>
### Register the webhook
1. Start a public HTTPS tunnel: `ngrok http 3000`
2. In your app dashboard, go to **WhatsApp → Configuration → Webhook → Edit**.
3. Set **Callback URL** to `https://<your-ngrok-url>/webhook`.
4. Set **Verify Token** to any string — you'll use this as `WHATSAPP_VERIFY_TOKEN`.
5. Click **Verify and Save**, then click **Manage** and **subscribe to the `messages` field**.
<Callout type="warn" title="Subscribe to messages">
Without subscribing to the `messages` field, Meta sends no inbound-message events and the bot will never receive a turn.
</Callout>
</Step>
<Step>
### Scaffold the project
```bash
mkdir my-whatsapp-bot && cd my-whatsapp-bot
npm init -y && npm pkg set type=module
```
Install the bot packages, plus `@copilotkit/runtime` for the in-process agent and `tsx` to run TypeScript directly:
<Tabs groupId="package-manager" items={['npm', 'pnpm', 'yarn']}>
<Tab value="npm">
```bash
npm install @copilotkit/bot @copilotkit/bot-ui @copilotkit/bot-whatsapp @copilotkit/runtime
npm install -D tsx typescript @types/node
```
</Tab>
<Tab value="pnpm">
```bash
pnpm add @copilotkit/bot @copilotkit/bot-ui @copilotkit/bot-whatsapp @copilotkit/runtime
pnpm add -D tsx typescript @types/node
```
</Tab>
<Tab value="yarn">
```bash
yarn add @copilotkit/bot @copilotkit/bot-ui @copilotkit/bot-whatsapp @copilotkit/runtime
yarn add -D tsx typescript @types/node
```
</Tab>
</Tabs>
Create a `tsconfig.json` that points the JSX factory at `@copilotkit/bot-ui`:
```json title="tsconfig.json"
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"],
"jsx": "react-jsx",
"jsxImportSource": "@copilotkit/bot-ui"
},
"include": ["bot.tsx"]
}
```
<Callout type="info" title="ESM only">
The bot packages are ESM-only — `"type": "module"` (set above) is required.
</Callout>
</Step>
<Step>
### Write the bot
The smallest working bot is `createBot` + the WhatsApp adapter + one `onMessage` handler that runs the agent. For the quickstart, the agent runs in-process:
```tsx title="bot.tsx"
import { createServer } from "node:http";
import { createBot } from "@copilotkit/bot";
import { whatsapp, defaultWhatsAppContext } from "@copilotkit/bot-whatsapp"; // [!code highlight]
import { BuiltInAgent, CopilotSseRuntime } from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
// The agent — runs in-process for the quickstart.
const runtime = new CopilotSseRuntime({
agents: {
assistant: new BuiltInAgent({
model: "openai/gpt-5.5", // reads OPENAI_API_KEY
prompt: "You are a helpful WhatsApp assistant. Keep replies short.",
}),
},
});
createServer(
createCopilotNodeListener({ runtime, basePath: "/api/copilotkit" }),
).listen(8200);
// The bot: the WhatsApp adapter + one handler.
const bot = createBot({
adapters: [
// [!code highlight:6]
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!,
}),
],
agent: (threadId) => {
const { HttpAgent } = await import("@copilotkit/bot");
const agent = new HttpAgent({
url: "http://localhost:8200/api/copilotkit/agent/assistant/run",
});
agent.threadId = threadId;
return agent;
},
context: [...defaultWhatsAppContext],
});
// WhatsApp has no @-mention — every inbound message is for the bot.
bot.onMessage(async ({ thread }) => {
await thread.runAgent(); // [!code highlight]
});
await bot.start();
console.log("WhatsApp bot listening for webhooks on port 3000");
```
`thread.runAgent()` sends the agent's complete reply as a WhatsApp message. Because WhatsApp messages are immutable, the response is buffered and sent once — there is no live streaming.
</Step>
<Step>
### Run it
```bash
export WHATSAPP_ACCESS_TOKEN=<your token>
export WHATSAPP_PHONE_NUMBER_ID=<your phone number id>
export WHATSAPP_APP_SECRET=<your app secret>
export WHATSAPP_VERIFY_TOKEN=<your verify token>
export OPENAI_API_KEY=sk-…
npx tsx bot.tsx
```
You should see `WhatsApp bot listening for webhooks on port 3000`. Now send a message to the test number from WhatsApp — in the Meta dashboard you can use the **Send test message** panel, or message the number directly from your phone if you added yourself as an allowed recipient.
<Accordions className="mb-4">
<Accordion title="Troubleshooting">
- **Webhook verification fails** — check that `WHATSAPP_VERIFY_TOKEN` matches exactly what you entered in the Meta app (no extra spaces).
- **No events arrive** — confirm you subscribed to the `messages` field in the webhook configuration, and that your tunnel is still running.
- **401 errors in logs** — `WHATSAPP_APP_SECRET` doesn't match the App Secret in **App Settings → Basic**.
- **Bot doesn't reply** — confirm the process is running and `OPENAI_API_KEY` is set.
</Accordion>
</Accordions>
</Step>
<Step>
### Post interactive UI
Replies can include interactive buttons (up to 3) or a list picker (4–10 items). Messages are authored as JSX from the [`@copilotkit/bot-ui` vocabulary](/reference/bot/components/Message). Replace the `onMessage` handler:
```tsx title="bot.tsx"
import { Message, Section, Actions, Button } from "@copilotkit/bot-ui"; // [!code highlight]
bot.onMessage(async ({ thread, message }) => {
if (message.text?.toLowerCase().includes("deploy")) {
await thread.post(
<Message>
<Section>Ship **v1.4.2** to production?</Section>
<Actions>
<Button
style="primary"
onClick={async ({ thread }) => {
await thread.post("Shipping!");
}}
>
Ship it
</Button>
<Button
onClick={async ({ thread }) => {
await thread.post("Standing down.");
}}
>
Cancel
</Button>
</Actions>
</Message>,
);
return;
}
await thread.runAgent();
});
```
Sending a message containing "deploy" triggers the interactive reply buttons. The handler code never leaves your process — WhatsApp only sees an opaque id in the button reply.
<Callout type="warn" title="Buttons expire on restart">
The default action store is **in-memory**: after a process restart, clicks on old buttons are acknowledged but ignored. For buttons that survive restarts, plug a durable store (Redis, a database) into `createBot({ actionStore })` — see the [ActionStore contract](/reference/bot/types/ActionStore).
</Callout>
</Step>
<Step>
### Add a command
Commands are matched by a leading keyword (default prefix `/`). Register its handler above `bot.start()`:
```tsx title="bot.tsx"
bot.onCommand("status", async ({ thread, text }) => {
await thread.runAgent({ prompt: `Status check: ${text}` }); // [!code highlight]
});
```
A user sending `/status production` triggers this handler. Unlike Slack, there is no native slash-command surface — commands are plain text messages that start with the prefix.
</Step>
The bot and its agent talk over AG-UI — an open protocol for agent ↔ frontend communication — so they don't have to share a process. Move the CopilotSseRuntime block into its own process (or use any existing AG-UI endpoint) and point the bot at it via AGENT_URL, exactly how the full on-call triage example is wired:
agent: (threadId) => {
const agent = new HttpAgent({ url: process.env.AGENT_URL! }); // [!code highlight]
agent.threadId = threadId;
return agent;
},
See the full example README for the complete two-process setup with Linear and Notion MCP integrations.
Up to 3 <Button> elements in an <Actions> block are rendered as WhatsApp
interactive reply buttons. Exceeding 3 automatically falls through to the list
picker path (4–10 items) or a numbered text menu (>10 items):
<Actions>
<Button onClick={...}>Approve</Button>
<Button onClick={...}>Reject</Button>
<Button onClick={...}>Defer</Button>
</Actions>
Button titles are capped at 20 characters (truncated with … if longer).
A <Select> with up to 10 options renders as a WhatsApp interactive list
message (a button that opens a scrollable picker):
<Select
placeholder="Choose severity"
options={[
{ label: "SEV1 — Critical", value: "sev1" },
{ label: "SEV2 — High", value: "sev2" },
{ label: "SEV3 — Medium", value: "sev3" },
]}
onSelect={async ({ thread, value }) => {
await thread.runAgent({ prompt: `Severity set to ${value}` });
}}
/>
Row titles are capped at 24 characters; descriptions at 72 characters.
Button and list-row ids carry both the minted action id and the option value
encoded as ${id}::${JSON.stringify(value)}. The total must stay under WhatsApp's
256-character id limit. If a value causes the encoded id to exceed this limit,
the adapter throws at render time — use a short key the handler maps to the full
value instead.
thread.stream buffers the full response and sends it as a single message.thread.lookupUser always returns undefined.HistoryStore via the adapter options.ActionStore.