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_KEYin 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 inAGENT_PROMPT.mdis replaced at runtime on every webhook call with the current active products - Realtime subscriptions in the Chats view use
postgres_changesfiltered byconversation_id - The toggle in the Menu view does an optimistic UI update while writing to Supabase