Skip to main content

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 single WABA number doing two jobs at once: pushing scheduled outbound campaigns (template messages) to opted-in recipients, and handling the inbound replies those campaigns generate — all with shared memory so the inbound agent knows what was sent, when, and to whom.

What you’ll build

A WhatsApp system that:
  • Schedules and sends outbound campaign templates to a segmented audience
  • Tracks delivery + read receipts + replies per recipient in memory
  • Routes inbound replies through a memory-aware agent that knows the campaign context
  • Respects opt-outs — STOP keywords flip a flag the campaigner reads before each send
Est. build time: 75–90 minutes.

When to use this recipe

Build this if:
  • You run outbound campaigns (announcements, re-engagement, reminders) on WhatsApp
  • You need the inbound response on those campaigns to feel coherent (“yes I want it” matches a specific template send)
  • Compliance (24-hour rule, opt-outs, template approval) matters
  • You want one number for both directions — not two separate systems

Architecture at a glance

Memory is the bridge. The inbound agent doesn’t need to be told “this person got campaign C-23 yesterday” — it pulls that from Synap.

Stack

LayerChoice
Synap SDKmaximem-synap (Python) / @maximem/synap (TypeScript)
WhatsAppWhatsApp Cloud API
SchedulerCelery + Redis (Python) / BullMQ + Redis (TypeScript) — or any cron-shaped runner
FrameworkOpenAI Agents SDK / Vercel AI SDK
LLMOpenAI gpt-4o-mini (cheap; replies are short)

Prerequisites

  • A Synap API key — see Authentication
  • A WABA number with approved template messages for your campaigns
  • A scheduler / queue (Celery, BullMQ, cron — your call)
  • Redis for opt-out flags, send dedupe, session windows
  • Python: Python 3.11+
  • TypeScript: Node 18+ and Python 3.11+ on the host
WhatsApp requires every outbound-to-cold-recipient message to use a pre-approved template. Free-form messages only work inside the 24-hour customer-initiated session window. Build this constraint into your scheduler, not the AI.

Install

pip install maximem-synap maximem-synap-openai-agents openai-agents heyoo celery redis

Build it

1. Identity & scoping

  • customer_id = "<your-business>"
  • user_id = <recipient phone> (hashed if you prefer)
  • conversation_id = <one per phone> — rolling

2. The outbound side — campaign sender

Templates are pre-approved on Meta. Your scheduler picks the audience, fills in template variables, and sends.
from celery import Celery
celery = Celery("campaigns", broker=os.environ["REDIS_URL"])

@celery.task
async def send_campaign(campaign_id: str, recipient_phone: str, vars: dict):
    if await is_opted_out(recipient_phone):
        return {"skipped": "opted_out"}

    # 1. Send the template via WABA
    wa_response = wa.send_template(
        recipient_id=recipient_phone,
        template=campaign_id,            # e.g. "may_relaunch_v3"
        components=template_components(vars),
    )

    # 2. Ingest the outbound into Synap so the inbound agent has context
    await sdk.memories.create(
        document=f"Outbound campaign: {campaign_id}\nVariables: {vars}",
        document_type="campaign-send",
        user_id=recipient_phone,
        customer_id=CUSTOMER_ID,
        metadata={
            "campaign_id": campaign_id,
            "wa_message_id": wa_response["messages"][0]["id"],
            "direction": "outbound",
        },
    )
    return {"sent": wa_response["messages"][0]["id"]}

3. Webhook — delivery / read receipts + inbound

The same webhook receives both delivery state updates and recipient replies.
@app.post("/webhook/whatsapp")
async def webhook(request: Request, bg: BackgroundTasks):
    body = await request.json()

    # Delivery / read receipts
    for status in iter_statuses(body):
        bg.add_task(record_delivery, status)

    # Inbound messages
    for inbound in iter_inbound(body):
        if is_optout(inbound["text"]):
            bg.add_task(mark_opted_out, inbound["from"])
            wa.send_message("You're opted out. Reply START to opt back in.", inbound["from"])
            continue
        bg.add_task(handle_reply, inbound["from"], inbound["text"], inbound["context"])

    return {"ok": True}

async def record_delivery(status: dict):
    await sdk.memories.create(
        document=f"Delivery status: {status['status']}",
        document_type="campaign-delivery",
        user_id=status["recipient_id"],
        customer_id=CUSTOMER_ID,
        metadata={"wa_message_id": status["id"], "status": status["status"]},
    )

4. The inbound reply agent

The agent reads the recent campaign-send and campaign-delivery memories before responding, so its reply matches the campaign that triggered the conversation.
SYSTEM = """You are a WhatsApp business agent. Your replies are short.

- The recipient may be responding to a recent campaign. Search memory for the most recent
  `campaign-send` document — if it's within the last 7 days, treat their reply as a
  response to that campaign and act on the campaign's intent.
- If they're asking a generic support question unrelated to recent campaigns, respond
  from general memory.
- Always honor opt-out (STOP, UNSUBSCRIBE, REMOVE) — but the webhook handles those before
  you ever see them, so if you receive the message it's not an opt-out.
- Keep replies under 2 sentences when possible."""

async def handle_reply(phone: str, text: str, ctx: dict):
    # Ingest the inbound first
    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",
            "in_reply_to_wa_id": ctx.get("id"),  # WA threads replies to specific msg
        },
    )

    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_reply_agent",
        instructions=SYSTEM,
        tools=[
            FunctionTool(synap_search, name_override="synap_search"),
            FunctionTool(synap_store,  name_override="synap_store"),
            # ...your business tools
        ],
    )
    result = await Runner.run(agent, input=text)
    reply = result.final_output

    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"},
    )

5. Opt-out handling

Compliance-critical. Two layers: STOP keyword in the webhook (immediate), and a Redis flag the sender checks before every send.
OPT_OUT_KEYWORDS = {"STOP", "UNSUBSCRIBE", "REMOVE", "QUIT"}

def is_optout(text: str) -> bool:
    return text.strip().upper() in OPT_OUT_KEYWORDS

async def mark_opted_out(phone: str):
    await redis.set(f"optout:{phone}", "1")
    await sdk.memories.create(
        document="Recipient opted out of campaigns.",
        document_type="campaign-optout",
        user_id=phone,
        customer_id=CUSTOMER_ID,
    )

async def is_opted_out(phone: str) -> bool:
    return bool(await redis.get(f"optout:{phone}"))

Run & verify

Campaign C-may-relaunch fires for phone +1555...
[scheduler] template send "may_relaunch_v3" → +1555…
[webhook]   delivered, read

Recipient (next morning): Interested — what's the price?

Agent (with campaign context loaded from memory):
"Glad to hear! The relaunch bundle is $49/mo with the migration done free if you sign
this week. Want a link to start?"

Recipient: yes please
Agent: Here you go: https://… — let me know if you hit anything weird.
The agent’s first reply references “the relaunch bundle” without being told which campaign because it pulled campaign-send: may_relaunch_v3 from memory.

Customize / extend

  • Add human handoff on top of campaigns → combine with Single-WABA Inbound + Human Handoff. Same memory, same scopes.
  • Multi-WABA for different campaign personas → see Multi-WABA Shared Memory.
  • Campaign performance memory → store reply rates and best-performing copy in a separate customer_id-scoped memory pool to inform next campaigns.
  • Replay opens / clicks from your CRM → use Patterns → Replay History to seed campaign engagement signal at launch.

Troubleshooting

Agent replies as if no campaign was sent
  • The agent’s synap_search isn’t fetching the recent campaign-send. Sharpen the system prompt to require a search first, or increase maxResults in the wrapper.
  • Verify record_delivery runs after send_campaign writes the campaign-send doc — race conditions can hide the outbound.
Sender keeps sending to opted-out recipients
  • The opt-out check belongs inside the worker task, not just at scheduling time. Audiences are computed in advance; opt-outs happen continuously.
Recipient gets duplicated outbound from the same campaign
  • Schedule with idempotency keys: (campaign_id, recipient_phone) → dedupe in Redis with a 30-day TTL.
Replies come in after the 24-hour window
  • Outside the window, free-form outbound is blocked by WhatsApp. Auto-fall-back to a “please-restart-conversation” template, or stay silent until the recipient initiates.