> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runflow.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# WhatsApp Agent from Scratch

> End-to-end walkthrough: create the connector and credential, handle Meta's webhook handshake, and go from zero to a live WhatsApp agent

This guide walks the **whole path** to a working WhatsApp agent — nothing assumed, nothing skipped:

1. Create the **WhatsApp connector** with its credential and resources (prebuilt or from scratch)
2. Write the **complete agent** with the [Channels module](/core-concepts/channels)
3. Grab the agent URL and enable **raw mode** (`&raw=true`)
4. Pass **Meta's webhook verification handshake**
5. Send a message and watch the agent reply

The flow you are building:

```
WhatsApp user → Meta Cloud API → your agent URL (?raw=true)
                                        │ parse (channels)
                                        ▼
                                    your turn (Agent)
                                        │ reply (channels)
                                        ▼
                     connector send-message → Meta Cloud API → WhatsApp user
```

## Prerequisites

* A **Meta developer app** ([developers.facebook.com](https://developers.facebook.com)) with the **WhatsApp** product added. The API Setup page gives you a test phone number for free.
* The **Phone Number ID** — shown on *WhatsApp → API Setup* (it is not the phone number itself).
* An **access token**. The temporary token from API Setup works for testing (expires in 24h); for anything real, create a **System User token** in Meta Business Settings with the `whatsapp_business_messaging` permission — it doesn't expire.
* A Runflow agent project (`rf create`) with `@runflow-ai/sdk` **v1.5.0+**.

<Steps>
  <Step title="Create the connector and credential">
    The channel provider sends every outbound message through a **connector**, so the Meta token lives server-side — your agent code never sees it. Two ways to get the connector:

    <Tabs>
      <Tab title="Prebuilt connector (recommended)">
        In the portal, open **Connectors** and pick **WhatsApp Business Cloud** from the prebuilt catalog. The template comes preconfigured with the Graph API base URL (`https://graph.facebook.com/v18.0`) and the auth shape — you only fill in the **Access Token** credential, which is sent as `Authorization: Bearer <token>` on every call.

        The prebuilt connector already ships the two resources the channel provider uses:

        | Resource slug   | What it does                                                                                                         |
        | --------------- | -------------------------------------------------------------------------------------------------------------------- |
        | `send-message`  | Raw passthrough to `POST /{phone_number_id}/messages` — one resource for every message type, mark-as-read and typing |
        | `get-media-url` | `GET /{media_id}` — resolves an inbound media id to its download URL                                                 |

        <Note>
          If your WhatsApp connector was created before the `send-message` **raw passthrough** existed, add it yourself with the exact definition from the "Custom connector" tab — the older per-type resources (`send-text-message`, etc.) are not what the provider uses.
        </Note>
      </Tab>

      <Tab title="Custom connector (from scratch)">
        If you prefer to own the connector (or need a different Graph API version), create one from zero: **Connectors → Create Connector**, type **REST API**, base URL:

        ```
        https://graph.facebook.com/v18.0
        ```

        Attach a **credential** holding your Meta access token, sent as a header: `Authorization: Bearer {token}`.

        Then create the two resources the provider calls:

        | Name                           | Slug            | Method | Path                          |
        | ------------------------------ | --------------- | ------ | ----------------------------- |
        | Send Message (raw passthrough) | `send-message`  | `POST` | `/{phone_number_id}/messages` |
        | Get Media URL                  | `get-media-url` | `GET`  | `/{media_id}`                 |

        Two rules that make or break this path:

        * **`send-message` must forward the request `body` untouched.** The channel provider builds the complete Graph API payload (`messaging_product`, `to`, `type`, the type-specific object — and also mark-as-read/typing payloads). Don't restrict the body schema to specific fields; declare it as a free-form object.
        * **Keep the slugs above**, or pass yours to the provider: `meta({ sendResource: 'my-slug', mediaResource: 'my-other-slug' })`.
      </Tab>
    </Tabs>

    Whichever path you took, note the **instance slug** of your connector (e.g. `whatsapp-acme`) — you'll pass it to `meta()` in the next step.

    <Tip>
      Smoke-test the credential before writing any code: on the connector's detail page, open the `send-message` resource in the request builder and send a minimal body (`{ "messaging_product": "whatsapp", "to": "<your number>", "type": "text", "text": { "body": "ping" } }`) with your `phone_number_id` as the path param. If Graph returns `401`, fix the token now — not after deploying.
    </Tip>
  </Step>

  <Step title="Write the agent (the complete project)">
    This is the **entire agent** — four files, all shown in full. Copy them as-is, change the connector slug, phone number id and verify token, and you have a working WhatsApp agent.

    ```
    whatsapp-agent/
    ├── main.ts              # entry point: handshake + channel handler
    ├── meta-handshake.ts    # Meta webhook verification
    └── agent/
        ├── agent.ts         # the Agent definition
        └── runner.ts        # the turn: events in → replies out
    ```

    The entry point does two jobs: answer Meta's verification handshake and delegate everything else to the channel handler.

    ```typescript main.ts theme={null}
    import { createChannelHandler, meta } from '@runflow-ai/sdk/channels';
    import { metaHandshake } from './meta-handshake';
    import { runTurn } from './agent/runner';

    const handler = createChannelHandler({
      provider: meta({
        connector: 'whatsapp-acme',        // your connector instance slug
        phoneNumberId: '123456789012345',  // from WhatsApp → API Setup
      }),
      run: runTurn,
    });

    export async function main(input: any) {
      // Meta verifies the webhook with a GET before delivering any events.
      // In raw mode the returned string is echoed back as plain text — exactly
      // what Meta's validator expects.
      const challenge = metaHandshake(input);
      if (challenge !== null) return challenge;

      return handler(input);
    }
    ```

    The handshake module. Three details matter here: a request without `hub.*` params is a **normal event, not an error**; the **verify token must be checked** (otherwise anyone who finds your URL can pass verification); and query values arrive as **arrays** (one entry per repeated param), so normalize both shapes:

    ```typescript meta-handshake.ts theme={null}
    // Meta webhook verification handshake:
    //   GET ?hub.mode=subscribe&hub.verify_token=<yours>&hub.challenge=<random>
    // Meta expects the bare challenge string back with HTTP 200.

    /** Any string you choose — you'll type this SAME value in Meta's webhook config. */
    const VERIFY_TOKEN = 'runflow-whatsapp-2026';

    function queryParam(
      query: Record<string, unknown>,
      name: string,
    ): string | undefined {
      const value = query[name];
      if (Array.isArray(value)) {
        return typeof value[0] === 'string' ? value[0] : undefined;
      }
      return typeof value === 'string' ? value : undefined;
    }

    /**
     * Returns the challenge to echo back when the request is a valid handshake,
     * or `null` when it isn't a handshake at all (a normal webhook event —
     * let the channel handler process it).
     *
     * Throws on a handshake with a wrong verify token, so verification fails
     * loudly instead of silently accepting anyone.
     */
    export function metaHandshake(input: {
      request?: { query?: Record<string, unknown> };
    }): string | null {
      const query = input?.request?.query;
      if (!query) return null;
      if (queryParam(query, 'hub.mode') !== 'subscribe') return null;

      if (queryParam(query, 'hub.verify_token') !== VERIFY_TOKEN) {
        throw new Error(
          'Meta webhook verification failed: hub.verify_token does not match VERIFY_TOKEN',
        );
      }

      const challenge = queryParam(query, 'hub.challenge');
      if (!challenge) {
        throw new Error('Meta webhook verification failed: hub.challenge missing');
      }
      return challenge;
    }
    ```

    The Agent — instructions, model and conversation memory:

    ```typescript agent/agent.ts theme={null}
    import { Agent } from '@runflow-ai/sdk';
    import { openai } from '@runflow-ai/sdk/models';

    export const agent = new Agent({
      name: 'whatsapp-assistant',
      instructions: `You are Acme's WhatsApp assistant.
    Answer in the user's language. Be brief — this is chat, not email.
    If you don't know something, say so instead of guessing.`,
      model: openai('gpt-4o'),
      memory: { maxTurns: 20 },
    });
    ```

    The turn. The platform may deliver a burst of quick messages as **one turn with several events** — flatten them into a single message, call the agent once, reply once:

    ```typescript agent/runner.ts theme={null}
    import { reply, type RunTurn } from '@runflow-ai/sdk/channels';
    import { agent } from './agent';

    export const runTurn: RunTurn = async ({ ctx, events }) => {
      const parts: string[] = [];

      for (const event of events) {
        switch (event.type) {
          case 'text':
            parts.push(event.text);
            break;
          case 'button':
            // The user tapped a quick-reply button — the label is the intent
            parts.push(event.text || event.payload);
            break;
          case 'audio':
            parts.push(
              '[The user sent a voice note. Tell them you cannot listen to audio yet and ask them to type it.]',
            );
            break;
          case 'image':
          case 'video':
          case 'document':
            parts.push(
              event.caption
                ? `[${event.type} received] ${event.caption}`
                : `[${event.type} received]`,
            );
            break;
          case 'location':
            parts.push(`[location] lat ${event.latitude}, lng ${event.longitude}`);
            break;
          default:
            // 'contacts', 'flow-reply', 'unknown' — inspect event.raw when you need them
            break;
        }
      }

      // Nothing actionable in this burst (e.g. only unsupported events): stay silent.
      if (parts.length === 0) return [];

      const result = await agent.process({
        message: parts.join('\n'),
        userId: ctx.userId,     // the sender's WhatsApp number, already normalized
        sessionId: ctx.userId,  // one conversation thread per user
      });

      return [reply.text(result.message)];
    };
    ```

    <Note>
      Want real audio support? Resolve the media with `fetchMedia` and transcribe it with the SDK — see [Channels → Media](/core-concepts/channels#media-audio-images-files). On Meta the download URL is Bearer-gated, so plan for a `mediaResource` that returns the bytes.
    </Note>

    <Warning>
      If `tsc` fails with `TS2307: Cannot find module '@runflow-ai/sdk/channels' ... under your current 'moduleResolution' setting`, set `"moduleResolution": "bundler"` in your `tsconfig.json`. Older `rf create` scaffolds ship `"moduleResolution": "node"`, which cannot resolve the SDK's subpath exports.
    </Warning>

    Deploy it:

    ```bash theme={null}
    rf agents deploy
    ```

    `rf agents deploy` always targets **staging** — you'll validate there first and [promote to production](/core-concepts/environments) later.
  </Step>

  <Step title="Grab the agent URL and add raw=true">
    Open your agent in the portal (**Agents → your agent**) and copy the **endpoint URL** from the info panel. It looks like:

    ```
    https://executor.staging.runflow.ai/agent/<agentId>?token=agt_...
    ```

    (Staging and production have different hosts — copy the URL for the environment you deployed to.)

    Now append `&raw=true`:

    ```
    https://executor.staging.runflow.ai/agent/<agentId>?token=agt_...&raw=true
    ```

    **Why raw mode matters here:** by default the endpoint wraps your agent's return value in a JSON envelope (`{ message, metadata, ... }`). Meta's webhook validator expects the **bare challenge string** back — `12345`, not `{"message":"12345"}`. With `raw=true`:

    * The response body is **exactly what `main` returned** — a string goes out as `text/plain`, unquoted
    * Execution metadata moves to response headers (`X-Runflow-Execution-Id`, `X-Runflow-Duration-Ms`, …)

    Without `&raw=true` the handshake **will fail** — this is the most common mistake in this whole flow.

    <Warning>
      The `token` in the URL is your agent's access token. The full URL is a secret — anyone holding it can execute your agent.
    </Warning>

    You can verify the handshake yourself before involving Meta:

    ```bash theme={null}
    curl "https://executor.staging.runflow.ai/agent/<agentId>?token=agt_...&raw=true&hub.mode=subscribe&hub.verify_token=runflow-whatsapp-2026&hub.challenge=12345"
    # → 12345
    ```

    If that prints `12345` (and nothing else), you're ready. Try it with a wrong `hub.verify_token` too — it must **not** return the challenge.
  </Step>

  <Step title="Configure the webhook in Meta">
    In your Meta app: **WhatsApp → Configuration → Webhook → Edit**:

    * **Callback URL** — your agent URL **including** `&raw=true`
    * **Verify token** — the exact `VERIFY_TOKEN` value from `meta-handshake.ts`

    Click **Verify and save**. Meta sends the `GET` handshake; your agent echoes the challenge; Meta accepts.

    Then, still on the Configuration page, **subscribe to the `messages` webhook field** — without this subscription Meta verifies fine but never delivers a single message (the second most common mistake).

    <Note>
      The verification handshake runs a real execution — you'll see it in [Observability](/core-concepts/observability), which is also a nice first confirmation that the wiring works.
    </Note>
  </Step>

  <Step title="Send a message">
    Message the WhatsApp number (on a test number, add your phone to the recipient allowlist on *API Setup* first). You should get the agent's reply within a few seconds.

    Behind the scenes: Meta POSTs the webhook → the `meta()` provider parses it into a `text` event with `userId` = the sender's number (Brazilian numbers normalized automatically) → your `runTurn` runs → `reply.text(...)` is rendered to the Graph API format and POSTed through your connector's `send-message` resource.

    Check **Observability → Executions** to watch the full trace, including the outbound connector call.
  </Step>
</Steps>

## Troubleshooting

| Symptom                                        | Cause                                                                                                                                                    |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Meta says "callback URL couldn't be validated" | Missing `&raw=true`; verify token mismatch; or the agent isn't deployed to the environment of the URL you used. Test with the `curl` above.              |
| Handshake OK, but messages never arrive        | You didn't subscribe to the `messages` webhook field, or (test numbers) the sender isn't in the recipient allowlist.                                     |
| Execution runs but no reply is sent            | Wrong `connector` slug or `phoneNumberId` — the provider fails loud with a legible error in the execution trace.                                         |
| Connector call returns `401`                   | Token expired (temporary tokens last 24h) or missing `whatsapp_business_messaging` permission. Swap in a System User token.                              |
| Connector call returns `400` from Graph        | Your custom `send-message` resource is rewriting or restricting the body — it must forward the provider's payload verbatim.                              |
| Replies arrive as `[object Object]` or JSON    | You bypassed the channel handler and returned an object from a hand-rolled send — return `OutboundMessage[]` from `runTurn` and let the provider render. |

## Going to production

* **Promote the agent** to production and update Meta's callback URL to the production endpoint (`executor.runflow.ai`) — or use a separate Meta app per environment.
* **This direct URL is synchronous**: Meta waits while your turn runs, and retries on timeout — there is no dedup or burst batching on this path. It's perfect to get live and for moderate traffic; for high-volume production, front the agent with an **HTTP trigger** (debounce/coalescing delivers the burst as `input.events[]`) — the channel handler works unchanged on both paths.
* **Media**: inbound audio/images arrive as media events; resolve them with `fetchMedia` and the `get-media-url` resource — see [Channels → Media](/core-concepts/channels#media-audio-images-files).

## Next Steps

<CardGroup cols={2}>
  <Card title="Channels" icon="comments" href="/core-concepts/channels">
    Buttons, media, typing indicators, multi-channel routing
  </Card>

  <Card title="Connectors" icon="plug" href="/core-concepts/connectors">
    How connector instances, credentials and resources work
  </Card>

  <Card title="Memory" icon="brain" href="/core-concepts/memory">
    Persist the conversation across messages
  </Card>

  <Card title="Observability" icon="chart-line" href="/core-concepts/observability">
    Trace every execution, including connector calls
  </Card>
</CardGroup>
