Back to Copilotkit

WhatsApp

showcase/shell-docs/src/content/docs/frontends/whatsapp.mdx

1.61.113.7 KB
Original Source

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.

Prerequisites

  • Node.js 20+
  • A Meta developer account with a WhatsApp Business App (the free test environment is fine)
  • An OpenAI API key (or Anthropic/Google — any model the built-in agent supports)
  • A public HTTPS tunnel to your local port (e.g. ngrok http 3000)

Getting started

<Steps> <Step> ### Create the Meta app and add WhatsApp
    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>
</Steps>

Split the bot and the agent

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:

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

Interactive components

Button → reply buttons

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):

tsx
<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).

Select → list message

A <Select> with up to 10 options renders as a WhatsApp interactive list message (a button that opens a scrollable picker):

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

Control value encoding

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.

Known limitations (v1)

  • No message editing or streaming — WhatsApp messages are immutable. thread.stream buffers the full response and sends it as a single message.
  • 24-hour customer-service window — the bot can only reply to users who sent a message within the last 24 hours. Proactive messaging requires pre-approved message templates, which this adapter does not implement.
  • 3 reply buttons / 10 list rows — interactive components degrade to a numbered text menu beyond these limits.
  • No user directorythread.lookupUser always returns undefined.
  • In-memory history by default — conversation history is lost on restart unless you provide a durable HistoryStore via the adapter options.
  • In-memory action store by default — inline button handlers expire on restart unless you provide a durable ActionStore.

Next steps