whatsapp-agent

star 0

Build a full-stack WhatsApp AI agent with Next.js, Supabase, and OpenAI. Use when creating WhatsApp chatbots, business messaging automation, or customer service agents integrated with a real-time dashboard for managing conversations and products.

giovannicirrincione By giovannicirrincione schedule Updated 3/7/2026

name: whatsapp-agent description: Build a full-stack WhatsApp AI agent with Next.js, Supabase, and OpenAI. Use when creating WhatsApp chatbots, business messaging automation, or customer service agents integrated with a real-time dashboard for managing conversations and products.

WhatsApp Agent Skill

Full-stack WhatsApp AI agent using Next.js App Router, Supabase, OpenAI GPT-4, and Meta's WhatsApp Cloud API.

Architecture Overview

Meta Webhook → /api/webhook → OpenAI GPT-4 → WhatsApp Cloud API
                    ↕
              Supabase (messages, conversations, products)
                    ↕
              Next.js Dashboard (auth, chats, menu management)

BACKEND — WhatsApp Webhook

File: app/api/webhook/route.ts

import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import OpenAI from 'openai'
import fs from 'fs'
import path from 'path'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // service role to bypass RLS
)

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })

// GET — Meta webhook verification
export async function GET(req: NextRequest) {
  console.log('[Webhook GET] Verification request received')
  const { searchParams } = new URL(req.url)
  const mode = searchParams.get('hub.mode')
  const token = searchParams.get('hub.verify_token')
  const challenge = searchParams.get('hub.challenge')

  if (mode === 'subscribe' && token === process.env.WHATSAPP_VERIFY_TOKEN) {
    console.log('[Webhook GET] Verified successfully')
    return new NextResponse(challenge, { status: 200 })
  }
  return new NextResponse('Forbidden', { status: 403 })
}

// POST — Incoming WhatsApp messages
export async function POST(req: NextRequest) {
  console.log('[Webhook POST] Incoming message received')
  try {
    const body = await req.json()

    // Parse Meta's deeply nested payload
    const entry = body?.entry?.[0]
    const change = entry?.changes?.[0]
    const value = change?.value
    const message = value?.messages?.[0]

    if (!message || message.type !== 'text') {
      return NextResponse.json({ status: 'ignored' }, { status: 200 })
    }

    const phoneNumber = message.from
    const userText = message.text.body

    // 1. Fetch active products from Supabase
    console.log('[Supabase] Fetching active products...')
    const { data: products, error: productsError } = await supabase
      .from('products')
      .select('name, price, description')
      .eq('is_active', true)

    if (productsError) {
      console.error('[Supabase] Error fetching products:', productsError)
    }

    const menuText = products && products.length > 0
      ? products.map(p => `- ${p.name}: $${p.price}${p.description ? ` (${p.description})` : ''}`).join('\n')
      : '(No hay productos disponibles en este momento)'

    // 2. Load system prompt and inject active menu
    const promptPath = path.join(process.cwd(), 'AGENT_PROMPT.md')
    let systemPrompt = fs.readFileSync(promptPath, 'utf-8')
    systemPrompt = systemPrompt.replace('{{MENU_ACTIVO}}', menuText)

    // 3. Get or create conversation
    console.log('[Supabase] Upserting conversation for:', phoneNumber)
    const { data: conversation, error: convError } = await supabase
      .from('conversations')
      .upsert({ phone_number: phoneNumber, updated_at: new Date().toISOString() }, { onConflict: 'phone_number' })
      .select()
      .single()

    if (convError) {
      console.error('[Supabase] Error upserting conversation:', convError)
      throw convError
    }

    // 4. Store user message
    console.log('[Supabase] Storing user message...')
    const { error: userMsgError } = await supabase
      .from('messages')
      .insert({ conversation_id: conversation.id, role: 'user', content: userText })

    if (userMsgError) {
      console.error('[Supabase] Error storing user message:', userMsgError)
    }

    // 5. Call OpenAI GPT-4
    const completion = await openai.chat.completions.create({
      model: 'gpt-4',
      messages: [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: userText },
      ],
    })
    const aiResponse = completion.choices[0].message.content ?? ''

    // 6. Store AI response
    console.log('[Supabase] Storing AI response...')
    const { error: aiMsgError } = await supabase
      .from('messages')
      .insert({ conversation_id: conversation.id, role: 'assistant', content: aiResponse })

    if (aiMsgError) {
      console.error('[Supabase] Error storing AI message:', aiMsgError)
    }

    // 7. Send reply via WhatsApp Cloud API
    await fetch(
      `https://graph.facebook.com/v19.0/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.WHATSAPP_ACCESS_TOKEN}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          messaging_product: 'whatsapp',
          to: phoneNumber,
          type: 'text',
          text: { body: aiResponse },
        }),
      }
    )

    return NextResponse.json({ status: 'ok' }, { status: 200 })
  } catch (err) {
    console.error('[Webhook POST] Unhandled error:', err)
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
  }
}

DATABASE — Supabase Schema

File: supabase/schema.sql

-- Enable UUID extension
create extension if not exists "uuid-ossp";

-- Conversations table
create table conversations (
  id uuid primary key default uuid_generate_v4(),
  phone_number text unique not null,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Messages table
create table messages (
  id uuid primary key default uuid_generate_v4(),
  conversation_id uuid references conversations(id) on delete cascade,
  role text not null check (role in ('user', 'assistant')),
  content text not null,
  created_at timestamptz default now()
);

-- Products table
create table products (
  id uuid primary key default uuid_generate_v4(),
  name text not null,
  description text,
  price numeric not null,
  is_active boolean default true,
  created_at timestamptz default now()
);

-- Enable Realtime
alter publication supabase_realtime add table conversations;
alter publication supabase_realtime add table messages;
alter publication supabase_realtime add table products;

-- RLS: enable on all tables
alter table conversations enable row level security;
alter table messages enable row level security;
alter table products enable row level security;

-- Service role has full access (bypasses RLS automatically)
-- Authenticated users: read conversations and messages
create policy "Authenticated users can read conversations"
  on conversations for select to authenticated using (true);

create policy "Authenticated users can read messages"
  on messages for select to authenticated using (true);

create policy "Authenticated users can read products"
  on products for select to authenticated using (true);

create policy "Authenticated users can update products"
  on products for update to authenticated using (true);

FRONTEND — Business Dashboard

Login Page: app/login/page.tsx

'use client'
import { useState } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { useRouter } from 'next/navigation'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const supabase = createClientComponentClient()
  const router = useRouter()

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault()
    const { error } = await supabase.auth.signInWithPassword({ email, password })
    if (error) { setError(error.message); return }
    router.push('/dashboard')
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-950">
      <form onSubmit={handleLogin} className="bg-gray-900 p-8 rounded-xl w-full max-w-sm space-y-4">
        <h1 className="text-white text-2xl font-bold">WhatsApp Agent</h1>
        {error && <p className="text-red-400 text-sm">{error}</p>}
        <input
          type="email" placeholder="Email" value={email}
          onChange={e => setEmail(e.target.value)}
          className="w-full p-3 rounded-lg bg-gray-800 text-white border border-gray-700 focus:outline-none focus:border-green-500"
        />
        <input
          type="password" placeholder="Password" value={password}
          onChange={e => setPassword(e.target.value)}
          className="w-full p-3 rounded-lg bg-gray-800 text-white border border-gray-700 focus:outline-none focus:border-green-500"
        />
        <button type="submit"
          className="w-full p-3 bg-green-600 hover:bg-green-500 text-white rounded-lg font-semibold transition">
          Iniciar sesión
        </button>
      </form>
    </div>
  )
}

Dashboard Layout: app/dashboard/layout.tsx

'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { MessageSquare, ShoppingBag } from 'lucide-react'

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  const pathname = usePathname()

  const navItems = [
    { href: '/dashboard/chats', label: 'Chats', icon: MessageSquare },
    { href: '/dashboard/menu', label: 'Menú', icon: ShoppingBag },
  ]

  return (
    <div className="flex h-screen bg-gray-950 text-white">
      <aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col p-4 gap-2">
        <h2 className="text-green-400 font-bold text-lg mb-4">Panel</h2>
        {navItems.map(({ href, label, icon: Icon }) => (
          <Link key={href} href={href}
            className={`flex items-center gap-3 px-3 py-2 rounded-lg transition ${
              pathname.startsWith(href) ? 'bg-green-700 text-white' : 'text-gray-400 hover:bg-gray-800'
            }`}>
            <Icon size={18} />
            {label}
          </Link>
        ))}
      </aside>
      <main className="flex-1 overflow-auto p-6">{children}</main>
    </div>
  )
}

Chats View: app/dashboard/chats/page.tsx

'use client'
import { useEffect, useState } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'

export default function ChatsPage() {
  const supabase = createClientComponentClient()
  const [conversations, setConversations] = useState<any[]>([])
  const [selected, setSelected] = useState<any>(null)
  const [messages, setMessages] = useState<any[]>([])

  useEffect(() => {
    supabase.from('conversations').select('*').order('updated_at', { ascending: false })
      .then(({ data }) => setConversations(data ?? []))
  }, [])

  useEffect(() => {
    if (!selected) return
    supabase.from('messages').select('*').eq('conversation_id', selected.id).order('created_at')
      .then(({ data }) => setMessages(data ?? []))

    // Realtime subscription
    const channel = supabase.channel(`messages:${selected.id}`)
      .on('postgres_changes', {
        event: 'INSERT', schema: 'public', table: 'messages',
        filter: `conversation_id=eq.${selected.id}`
      }, payload => setMessages(prev => [...prev, payload.new]))
      .subscribe()

    return () => { supabase.removeChannel(channel) }
  }, [selected])

  return (
    <div className="flex h-full gap-4">
      {/* Conversation list */}
      <div className="w-64 bg-gray-900 rounded-xl p-3 flex flex-col gap-2 overflow-y-auto">
        <h2 className="text-white font-semibold mb-2">Conversaciones</h2>
        {conversations.map(c => (
          <button key={c.id} onClick={() => setSelected(c)}
            className={`text-left p-3 rounded-lg transition ${
              selected?.id === c.id ? 'bg-green-700' : 'bg-gray-800 hover:bg-gray-700'
            }`}>
            <p className="text-white text-sm font-medium">{c.phone_number}</p>
            <p className="text-gray-400 text-xs">{new Date(c.updated_at).toLocaleString()}</p>
          </button>
        ))}
      </div>

      {/* Message thread */}
      <div className="flex-1 bg-gray-900 rounded-xl p-4 flex flex-col gap-3 overflow-y-auto">
        {!selected && <p className="text-gray-500 m-auto">Seleccioná una conversación</p>}
        {messages.map(m => (
          <div key={m.id} className={`max-w-[70%] px-4 py-2 rounded-2xl text-sm ${
            m.role === 'user'
              ? 'bg-gray-700 text-white self-start'
              : 'bg-green-700 text-white self-end'
          }`}>
            {m.content}
          </div>
        ))}
      </div>
    </div>
  )
}

Menu View: app/dashboard/menu/page.tsx

'use client'
import { useEffect, useState } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'

export default function MenuPage() {
  const supabase = createClientComponentClient()
  const [products, setProducts] = useState<any[]>([])

  useEffect(() => {
    supabase.from('products').select('*').order('created_at')
      .then(({ data }) => setProducts(data ?? []))
  }, [])

  const toggleActive = async (id: string, current: boolean) => {
    await supabase.from('products').update({ is_active: !current }).eq('id', id)
    setProducts(prev => prev.map(p => p.id === id ? { ...p, is_active: !current } : p))
  }

  return (
    <div>
      <h2 className="text-white text-xl font-bold mb-4">Menú</h2>
      <div className="bg-gray-900 rounded-xl overflow-hidden">
        <table className="w-full text-sm">
          <thead className="bg-gray-800 text-gray-400">
            <tr>
              <th className="text-left p-4">Producto</th>
              <th className="text-left p-4">Descripción</th>
              <th className="text-left p-4">Precio</th>
              <th className="text-left p-4">Activo</th>
            </tr>
          </thead>
          <tbody>
            {products.map(p => (
              <tr key={p.id} className="border-t border-gray-800 hover:bg-gray-800/50 transition">
                <td className="p-4 text-white font-medium">{p.name}</td>
                <td className="p-4 text-gray-400">{p.description ?? '-'}</td>
                <td className="p-4 text-green-400">${p.price}</td>
                <td className="p-4">
                  <button
                    onClick={() => toggleActive(p.id, p.is_active)}
                    className={`w-12 h-6 rounded-full transition-colors relative ${
                      p.is_active ? 'bg-green-500' : 'bg-gray-600'
                    }`}>
                    <span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${
                      p.is_active ? 'left-7' : 'left-1'
                    }`} />
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  )
}

AGENT PROMPT FILE

File: AGENT_PROMPT.md (project root)

Sos el asistente virtual de atención al cliente de una hamburguesería local. Tu tono debe ser muy amable, cálido, rápido y conciso.

REGLAS CLAVE DE ATENCIÓN:

Saludo Inicial: Si el cliente dice "Hola" o es el primer contacto, saludá cordialmente dándole la bienvenida a la hamburguesería y preguntale en qué lo podés ayudar.

Solicitud de Carta: Si el cliente menciona palabras como "carta", "menú", "precios" o "opciones", debés enviarle inmediatamente la lista de productos disponibles que aparece abajo en la sección MENÚ DISPONIBLE.

Toma de Pedidos y Disponibilidad:
- Solo podés aceptar pedidos de los ítems que figuran explícitamente en la sección MENÚ DISPONIBLE.
- Si el cliente pide un producto que NO está en la lista (porque fue deshabilitado por falta de stock), debés informarle amablemente que en este momento se encuentra agotado o fuera de carta, y ofrecerle las alternativas que sí están disponibles.
- Una vez que el cliente confirme su pedido, hacé un resumen total del costo y confirmale que el pedido está en preparación.

MENÚ DISPONIBLE HOY:
{{MENU_ACTIVO}}

ENV VARS

File: .env.local.example

WHATSAPP_ACCESS_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_VERIFY_TOKEN=
OPENAI_API_KEY=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=

Key Implementation Notes

  • Always use SUPABASE_SERVICE_ROLE_KEY in the webhook API route for database reads/writes (bypasses RLS)
  • Use NEXT_PUBLIC_SUPABASE_ANON_KEY + cookie-based auth (@supabase/auth-helpers-nextjs) on the frontend
  • The {{MENU_ACTIVO}} placeholder in AGENT_PROMPT.md is replaced at runtime on every webhook call with the current active products
  • Realtime subscriptions in the Chats view use postgres_changes filtered by conversation_id
  • The toggle in the Menu view does an optimistic UI update while writing to Supabase
Install via CLI
npx skills add https://github.com/giovannicirrincione/whats-App-agent --skill whatsapp-agent
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
giovannicirrincione
giovannicirrincione Explore all skills →