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

# WhatsApp: Single-WABA Inbound + Human Handoff

> One WhatsApp Business number, AI takes inbound, drops cleanly to a human agent, picks back up afterward.

<Info>
  **Status:** In Development · Playground demo coming soon.
  The recipe below is complete and runnable today; only the hosted playground showcase is pending.
</Info>

A WhatsApp Business agent that handles inbound messages on one WABA number, knows when to hand off to a human, stays out of the way during the human conversation, and picks back up cleanly when the human releases. Memory carries across the whole arc: AI turns, human turns, and the resumption.

## What you'll build

A single-WABA inbound agent that:

* **Takes inbound WhatsApp messages** and replies in-thread
* **Detects handoff signals**: sentiment, keywords, explicit asks, repeat failure
* **Hands off to a human** by surfacing the conversation in your agent console / Slack with full memory context
* **Goes quiet** while the human owns the thread
* **Resumes** when the human releases: full context, no recap

**Est. build time:** 60-75 minutes (WhatsApp Cloud API setup is most of it).

## When to use this recipe

Build this if:

* You have one WABA number for one product or business line
* You have human agents available some of the time but want AI coverage the rest
* The handoff in and out has to feel seamless to the customer
* You're okay with conversation continuity tied to phone number (which it is on WhatsApp)

## Architecture at a glance

```mermaid theme={null}
flowchart TD
    Customer[WhatsApp customer] -->|inbound webhook| Backend[Your backend]
    Backend --> Fetch[(Synap context fetch)]
    Fetch --> Decision{Handoff state?}
    Decision -->|yes| Console[AI silent<br/>Forward to human console / Slack]
    Decision -->|no| LLM[LLM agent]
    LLM -->|reply| WA[WhatsApp Cloud API]
    WA --> Customer
    LLM -.->|fire-and-forget| Ingest[(Synap ingest turn)]
    Console -.->|human replies| WA
    Console -.->|click release| Decision
```

The "handoff state" is a single key per customer (`AI_HANDOFF[phone]`). When set, the AI agent stops responding. Human turns are still ingested into Synap so the AI has them when it resumes.

## Stack

| Layer             | Choice                                                                                                                |
| ----------------- | --------------------------------------------------------------------------------------------------------------------- |
| **Synap SDK**     | `maximem-synap` (Python) / `@maximem/synap-js-sdk` (TypeScript)                                                       |
| **WhatsApp**      | WhatsApp Cloud API (Meta), via `heyoo` (Python) or direct fetch (TS)                                                  |
| **Framework**     | [OpenAI Agents SDK](/integrations/openai-agents) (Python) / [Vercel AI SDK](/integrations/vercel-ai-sdk) (TypeScript) |
| **LLM**           | OpenAI `gpt-4o`                                                                                                       |
| **Handoff state** | Redis in production; in-memory dict for the demo                                                                      |

## Prerequisites

* A Synap API key. See [Authentication](/setup/authentication)
* A WABA number, Meta Business app, verified, with a webhook configured to point at your backend
* A System User access token with `whatsapp_business_messaging` scope
* A human console / Slack channel for handoffs
* **Python:** Python 3.11+
* **TypeScript:** Node 18+ **and** Python 3.11+ on the host

<Warning>
  TypeScript recipe runs on Node only. Pin Next.js route handlers to `export const runtime = "nodejs"`. WhatsApp webhooks expect synchronous 200 responses within 20s: return fast and process async. See [Installation → JavaScript / TypeScript SDK](/setup/installation#javascript-typescript-sdk).
</Warning>

### Install

<CodeGroup>
  ```bash Python theme={null}
  pip install maximem-synap maximem-synap-openai-agents openai-agents heyoo fastapi uvicorn
  ```

  ```bash uv theme={null}
  uv add maximem-synap maximem-synap-openai-agents openai-agents heyoo fastapi uvicorn
  # pip-compatible (existing venv): uv pip install maximem-synap maximem-synap-openai-agents openai-agents heyoo fastapi uvicorn
  ```

  ```bash TypeScript theme={null}
  npm install @maximem/synap-js-sdk @maximem/synap-vercel-adk ai @ai-sdk/openai
  ```
</CodeGroup>

### Configure

<CodeGroup>
  ```bash Python theme={null}
  # .env
  SYNAP_API_KEY=...
  OPENAI_API_KEY=...
  WABA_PHONE_NUMBER_ID=...
  WABA_ACCESS_TOKEN=...
  WABA_WEBHOOK_VERIFY_TOKEN=...
  ```

  ```bash TypeScript theme={null}
  # .env.local
  SYNAP_API_KEY=...
  OPENAI_API_KEY=...
  WABA_PHONE_NUMBER_ID=...
  WABA_ACCESS_TOKEN=...
  WABA_WEBHOOK_VERIFY_TOKEN=...
  ```
</CodeGroup>

## Build it

### 1. Identity & scoping

WhatsApp gives you a stable identifier: the customer's phone number. Use it as `user_id`.

* `customer_id = "<your-business>"`: your business, single tenant
* `user_id = <E.164 phone, hashed if you prefer>`
* `conversation_id = <one per phone>`: WhatsApp doesn't have explicit sessions; treat the whole relationship as one rolling conversation

<Note>
  `conversation_id`, `user_id`, and `customer_id` must be valid UUIDs. Since these key off the phone, derive a deterministic UUID from it with `uuid.uuid5(...)` (Python) rather than passing the raw phone string.
</Note>

<Note>
  If your privacy posture requires it, hash phone numbers (e.g., `sha256("e164:" + phone)`) before using them as `user_id`. Synap will treat the hash as the stable identifier; you keep raw phones in your own DB.
</Note>

### 2. Handoff state

<CodeGroup>
  ```python Python theme={null}
  # Set when AI hands off; cleared when human releases. Use Redis in production.
  AI_HANDOFF: set[str] = set()

  def is_handoff(phone: str) -> bool:
      return phone in AI_HANDOFF

  def start_handoff(phone: str) -> None:
      AI_HANDOFF.add(phone)

  def end_handoff(phone: str) -> None:
      AI_HANDOFF.discard(phone)
  ```

  ```typescript TypeScript theme={null}
  const AI_HANDOFF = new Set<string>();
  const isHandoff = (phone: string) => AI_HANDOFF.has(phone);
  const startHandoff = (phone: string) => AI_HANDOFF.add(phone);
  const endHandoff = (phone: string) => AI_HANDOFF.delete(phone);
  ```
</CodeGroup>

### 3. The handoff tool

The agent decides when to hand off. The tool fans out: persist a structured memory record + notify the human console.

<CodeGroup>
  ```python Python theme={null}
  @function_tool
  async def hand_off_to_human(phone: str, reason: str, summary: str) -> dict:
      """Bring a human into the thread. Reason: 'customer_asked' | 'frustration' | 'out_of_scope' | 'sensitive'."""
      start_handoff(phone)
      await sdk.memories.create(
          document=f"AI handed off to human. Reason: {reason}. Summary: {summary}",
          document_type="support-handoff",
          user_id=phone,
          customer_id=CUSTOMER_ID,
          metadata={"handoff_reason": reason},
      )
      await notify_human_console(phone, reason, summary)  # your Slack/console push
      return {"status": "handed_off"}
  ```

  ```typescript TypeScript theme={null}
  const handOffToHuman = tool({
    description: "Bring a human into the thread.",
    parameters: z.object({
      phone: z.string(),
      reason: z.enum(["customer_asked", "frustration", "out_of_scope", "sensitive"]),
      summary: z.string(),
    }),
    execute: async ({ phone, reason, summary }) => {
      startHandoff(phone);
      await synap.sdk.memories.create({
        document: `AI handed off to human. Reason: ${reason}. Summary: ${summary}`,
        documentType: "support-handoff",
        userId: phone,
        customerId: CUSTOMER_ID,
        metadata: { handoffReason: reason },
      });
      await notifyHumanConsole(phone, reason, summary);
      return { status: "handed_off" };
    },
  });
  ```
</CodeGroup>

### 4. The inbound webhook

WhatsApp webhooks deliver inbound messages. Return 200 fast, process async.

<CodeGroup>
  ```python Python theme={null}
  from fastapi import FastAPI, Request, BackgroundTasks
  from heyoo import WhatsApp

  app = FastAPI()
  wa = WhatsApp(token=os.environ["WABA_ACCESS_TOKEN"], phone_number_id=os.environ["WABA_PHONE_NUMBER_ID"])
  CUSTOMER_ID = "your-business"

  @app.post("/webhook/whatsapp")
  async def webhook(request: Request, bg: BackgroundTasks):
      body = await request.json()
      msg = wa.get_message(body)
      phone = wa.get_mobile(body)
      if msg and phone:
          bg.add_task(handle_inbound, phone, msg)
      return {"ok": True}

  async def handle_inbound(phone: str, text: str):
      # Always ingest the inbound, even during handoff
      await sdk.memories.create(
          document=f"Customer: {text}",
          document_type="ai-chat-conversation",
          user_id=phone,
          customer_id=CUSTOMER_ID,
          metadata={"channel": "whatsapp", "direction": "inbound"},
      )

      if is_handoff(phone):
          return  # human owns the thread; AI stays quiet

      reply = await run_ai(phone, text)
      wa.send_message(reply, phone)

      await sdk.memories.create(
          document=f"Agent: {reply}",
          document_type="ai-chat-conversation",
          user_id=phone,
          customer_id=CUSTOMER_ID,
          metadata={"channel": "whatsapp", "direction": "outbound", "tier": "ai"},
      )
  ```

  ```typescript TypeScript theme={null}
  // app/api/webhook/whatsapp/route.ts
  export const runtime = "nodejs";

  export async function POST(req: Request) {
    const body = await req.json();
    const { phone, text } = parseWaInbound(body);
    if (phone && text) {
      handleInbound(phone, text).catch(console.error); // fire-and-forget
    }
    return Response.json({ ok: true });
  }

  async function handleInbound(phone: string, text: string) {
    await synap.sdk.memories.create({
      document: `Customer: ${text}`,
      documentType: "ai-chat-conversation",
      userId: phone,
      customerId: CUSTOMER_ID,
      metadata: { channel: "whatsapp", direction: "inbound" },
    });

    if (isHandoff(phone)) return;

    const reply = await runAi(phone, text);
    await sendWaMessage(phone, reply);

    await synap.sdk.memories.create({
      document: `Agent: ${reply}`,
      documentType: "ai-chat-conversation",
      userId: phone,
      customerId: CUSTOMER_ID,
      metadata: { channel: "whatsapp", direction: "outbound", tier: "ai" },
    });
  }
  ```
</CodeGroup>

### 5. The AI agent

<CodeGroup>
  ```python Python theme={null}
  SYSTEM = """You are a WhatsApp business agent.

  - Be concise. WhatsApp users want short replies.
  - Recall the customer's history and prior preferences from memory.
  - Hand off to a human when: the customer explicitly asks, they're frustrated, the issue is sensitive
    (financial, medical, legal), or you've tried twice without resolution. Use hand_off_to_human.
  - Don't promise anything you can't deliver via tools."""

  async def run_ai(phone: str, text: str) -> str:
      synap_search = create_search_tool(sdk=sdk, user_id=phone, customer_id=CUSTOMER_ID)
      synap_store  = create_store_tool(sdk=sdk, user_id=phone, customer_id=CUSTOMER_ID)

      agent = Agent(
          name="wa_agent",
          instructions=SYSTEM,
          tools=[
              FunctionTool(synap_search, name_override="synap_search"),
              FunctionTool(synap_store,  name_override="synap_store"),
              hand_off_to_human,
              # ...your business tools
          ],
      )
      result = await Runner.run(agent, input=text)
      return result.final_output
  ```

  ```typescript TypeScript theme={null}
  const SYSTEM = `You are a WhatsApp business agent.

  - Be concise. WhatsApp users want short replies.
  - Recall the customer's history and prior preferences from memory.
  - Hand off when: the customer asks, they're frustrated, the issue is sensitive, or you've tried twice.
  - Don't promise anything you can't deliver via tools.`;

  async function runAi(phone: string, text: string): Promise<string> {
    const model = synap.wrap(openai("gpt-4o"), {
      userId: phone,
      customerId: CUSTOMER_ID,
      conversationId: phone, // one rolling conversation per phone
    });

    const { text: reply } = await generateText({
      model,
      system: SYSTEM,
      prompt: text,
      tools: { hand_off_to_human: handOffToHuman /* + your business tools */ },
    });
    return reply;
  }
  ```
</CodeGroup>

### 6. The human side

Two affordances in your console / Slack:

1. **Reply to the customer** → posts a message via WABA, ingests it into memory tagged `tier: "human"`.
2. **Release the thread** → calls `end_handoff(phone)`. Next customer message routes to the AI again, with full handoff history loaded from memory.

<CodeGroup>
  ```python Python theme={null}
  async def human_reply(phone: str, text: str):
      wa.send_message(text, phone)
      await sdk.memories.create(
          document=f"Human agent: {text}",
          document_type="ai-chat-conversation",
          user_id=phone,
          customer_id=CUSTOMER_ID,
          metadata={"channel": "whatsapp", "direction": "outbound", "tier": "human"},
      )

  async def human_release(phone: str):
      end_handoff(phone)
      await sdk.memories.create(
          document="Human released thread back to AI.",
          document_type="support-handoff",
          user_id=phone,
          customer_id=CUSTOMER_ID,
          metadata={"handoff_event": "released"},
      )
  ```

  ```typescript TypeScript theme={null}
  export async function humanReply(phone: string, text: string) {
    await sendWaMessage(phone, text);
    await synap.sdk.memories.create({
      document: `Human agent: ${text}`,
      documentType: "ai-chat-conversation",
      userId: phone,
      customerId: CUSTOMER_ID,
      metadata: { channel: "whatsapp", direction: "outbound", tier: "human" },
    });
  }

  export async function humanRelease(phone: string) {
    endHandoff(phone);
    await synap.sdk.memories.create({
      document: "Human released thread back to AI.",
      documentType: "support-handoff",
      userId: phone,
      customerId: CUSTOMER_ID,
      metadata: { handoffEvent: "released" },
    });
  }
  ```
</CodeGroup>

## Run & verify

```text theme={null}
Customer:  Hi, my order #ORD-22 hasn't arrived
AI:        Hey! ORD-22 is showing delivered 5/14 to 12 Oak St. If that's not your address,
           tell me your correct delivery address and I'll trace it.

Customer:  I never got it. This is the second time. I want to talk to a person.
AI:        Of course, connecting you with a teammate now.
           [hand_off_to_human(reason=customer_asked, summary="2nd missing-order issue,
            ORD-22 marked delivered. Customer wants human.")]

[AI goes silent. Human picks up in console:]

Customer:  ...

Human (in console):  Hi Anish, I see ORD-22 was marked delivered to 12 Oak. Apologies you didn't get it. I'm reissuing #ORD-22-R immediately, free shipping, you'll have it in 2 days. Also flagging your account so this can't happen a third time.
Customer:  Thank you.

[Human clicks "Release" in console. AI_HANDOFF cleared.]
```

```text Next day, new message theme={null}
Customer:  Hey, did the reissue ship?
AI:        Yes, ORD-22-R shipped 5/15, tracking #1Z999. Carrier ETA 5/17.
           Want me to send tracking updates here as they come in?
```

The next-day reply comes from the AI again, with the human's resolution and the reissue tracked in memory.

## Customize / extend

* **Multiple WABA numbers** → see [Multi-WABA Shared Memory](/cookbook/whatsapp-multi-waba-shared) for the routing pattern.
* **Outbound campaigns + inbound** → see [Single-WABA Campaign + Inbound](/cookbook/whatsapp-single-campaign).
* **Slack as the human console** → post handoff alerts into a `#wa-support` channel; replies posted in-thread relay back via WABA. Same pattern as [Patterns → Slack Bot](/patterns/slack-bot).
* **Auto-release on inactivity** → after N minutes of no human reply, clear `AI_HANDOFF` automatically (Redis TTL).
* **Tier-escalation flavor** → for AI→AI handoff instead of AI→human, see [Tier-1 → Tier-2 Escalation](/cookbook/support-tier-escalation).

## Troubleshooting

**AI replies during a human handoff**

* Race condition: webhook fired before `AI_HANDOFF` was set. Check `is_handoff` *inside* the async task, not in the webhook handler.

**AI loses context after the human releases**

* Confirm human turns are being ingested into memory with `tier: "human"` metadata. If they're missing, the AI sees a gap and may re-ask basic questions.

**Webhook timeouts on Vercel**

* WhatsApp expects a 200 within 20s. Return immediately from the route handler and run `handleInbound` in the background. Don't `await` it.

**Customer gets dupe messages**

* WhatsApp redelivers webhooks that don't 200 fast enough. Idempotency: track `message_id` in Redis with a short TTL and skip duplicates.

**Template messages required for outbound > 24h**

* WhatsApp's 24-hour rule: outside the customer-initiated session window, you can only send approved template messages. Track session start in Redis; refuse to send free-form replies outside the window.

## Related

* **Integrations:** [OpenAI Agents SDK](/integrations/openai-agents) · [Vercel AI SDK](/integrations/vercel-ai-sdk)
* **Concepts:** [Memory Scopes](/concepts/memory-scopes) · [Customer Context](/concepts/context-end-to-end#customer-context) · [Agent Interactions](/concepts/agent-topologies#agent-interactions)
* **Patterns:** [Slack Bot](/patterns/slack-bot) · [Graceful Degradation](/patterns/graceful-degradation) · [Multi-Tenant SaaS](/patterns/multi-tenant-saas)
* **Other recipes:** [WhatsApp Campaign + Inbound](/cookbook/whatsapp-single-campaign) · [Multi-WABA Shared Memory](/cookbook/whatsapp-multi-waba-shared) · [Tier Escalation](/cookbook/support-tier-escalation)
