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.
Status: In Development · Playground demo coming soon.
The recipe below is complete and runnable today — only the hosted playground showcase is pending.
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
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 (TypeScript) |
| WhatsApp | WhatsApp Cloud API (Meta), via heyoo (Python) or direct fetch (TS) |
| Framework | OpenAI Agents SDK (Python) / 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
- 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
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.
Install
pip install maximem-synap maximem-synap-openai-agents openai-agents heyoo fastapi uvicorn
# .env
SYNAP_API_KEY=...
SYNAP_SERVER_URL=<maximem-server>
OPENAI_API_KEY=...
WABA_PHONE_NUMBER_ID=...
WABA_ACCESS_TOKEN=...
WABA_WEBHOOK_VERIFY_TOKEN=...
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
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.
2. Handoff state
# 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)
The agent decides when to hand off. The tool fans out: persist a structured memory record + notify the human console.
@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"}
4. The inbound webhook
WhatsApp webhooks deliver inbound messages. Return 200 fast, process async.
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"},
)
5. The AI agent
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
6. The human side
Two affordances in your console / Slack:
- Reply to the customer → posts a message via WABA, ingests it into memory tagged
tier: "human".
- Release the thread → calls
end_handoff(phone). Next customer message routes to the AI again, with full handoff history loaded from memory.
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"},
)
Run & verify
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.]
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 for the routing pattern.
- Outbound campaigns + inbound → see Single-WABA Campaign + Inbound.
- 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.
- 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.
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.