Integrating a Groq‑Powered WhatsApp AI Advisor into a NestJS API (pilot)
TL;DR: Added a WhatsApp‑AI endpoint that calls Groq with function‑calling to fetch real‑estate property data. The change touches the NestJS module, env handling, DB migration, Groq helper, and new controller/service files.
The Problem
Our backend needed a way for brokers to answer property‑related questions directly from WhatsApp without leaving the chat. The existing API only exposed CRUD for properties; there was no AI layer, and we had no way to invoke Groq’s function‑calling feature from a webhook that WhatsApp sends. The symptom was a missing route and missing environment variables, resulting in a 404 from the client and a GROQ_API_KEY not configured error when we tried a quick prototype.
What I Tried First
I started with a naïve approach:
-
Direct HTTP call to Groq from the existing
PropertiesService. - Hard‑coded the prompt and ignored function‑calling.
The code compiled but failed at runtime:
Error: GROQ_API_KEY no configurada en el servidor.
I also attempted to add the new route directly inside app.controller.ts, but the controller became cluttered and the module registration was incomplete, leading to NestJS throwing:
Nest can't resolve dependencies of the WhatsAppAiController (?). Please make sure that the argument at index [0] is available in the current context.
Both attempts highlighted two gaps: missing environment configuration and missing modular separation.
The Implementation
1. Environment schema update (apps/api/src/common/env.ts)
@@ -42,6 +42,12 @@ const envSchema = z.object({
// ── ClickUp — OPTIONAL ────────────────────────────────────────────────────
CLICKUP_API_KEY: z.string().optional(),
CLICKUP_LIST_ID: z.string
+ // ── WhatsApp AI — OPTIONAL ───────────────────────────────────────────────
+ WHATSAPP_AI_ENABLED: z.string().optional(),
+ GROQ_API_KEY: z.string(),
+ GROQ_ENDPOINT: z.string().default('https://api.groq.com/openai/v1/chat/completions'),
+ WHATSAPP_WEBHOOK_SECRET: z.string().optional(),
+ WHATSAPP_BROKER_NUMBER: z.string().optional(),
});
Adding the keys forces the server to crash early if the Groq key is missing, preventing silent failures.
2. DB migration (apps/api/src/db/db.ts)
A new table whatsapp_ai_sessions stores conversation IDs and timestamps to enforce rate‑limits per broker.
@@ -1869,5 +1869,39 @@ export async function migrate(): Promise<void> {
);
create index if not exists idx_construction_inspections_proj on construction_inspections(construction_project_id, t
+ await db.exec(`
+ CREATE TABLE IF NOT EXISTS whatsapp_ai_sessions (
+ id SERIAL PRIMARY KEY,
+ broker_id VARCHAR NOT NULL,
+ session_id VARCHAR NOT NULL,
+ last_interaction TIMESTAMP NOT NULL DEFAULT now()
+ );
+ `);
+
+ await db.exec(`
+ CREATE INDEX IF NOT EXISTS idx_whatsapp_ai_sessions_broker
+ ON whatsapp_ai_sessions (broker_id);
+ `);
The migration is idempotent; running it on existing environments adds the table without affecting other data.
3. Groq helper extension (apps/api/src/shared/groq.helper.ts)
We added a wrapper that supports function‑calling.
@@ -51,3 +51,34 @@ export function getGroqKey(): string {
if (!key) throw new Error("GROQ_API_KEY no configurada en el servidor.");
return key;
}
+/** Variante con function-calling — usada por el asistente de WhatsApp */
+export async function callGroqWithTools(prompt: string, tools: any[]): Promise<any> {
+ const response = await fetch(env.GROQ_ENDPOINT, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${getGroqKey()}`,
+ },
+ body: JSON.stringify({
+ model: "mixtral-8x7b-32768",
+ messages: [{ role: "user", content: prompt }],
+ tools,
+ }),
+ });
+
+ if (!response.ok) {
+ const txt = await response.text();
+ throw new Error(`Groq request failed: ${response.status} ${txt}`);
+ }
+
+ const data = await response.json();
+ return data;
+}
The tools array defines the function schema that Groq can invoke (e.g., getPropertyById). This is critical for returning structured data instead of raw text.
4. New WhatsApp AI controller (apps/api/src/whatsapp-ai/whatsapp-ai.controller.ts)
import { Body, Controller, Get, Post, Query, Res } from "@nestjs/common";
import type { Response } from "express";
import { env } from "../common/env.js";
import { handleIncomingWebhook } from "./whatsapp-ai.service.js";
@Controller("whatsapp-ai")
export class WhatsAppAiController {
@Post("webhook")
async webhook(@Body() payload: any, @Res() res: Response) {
// Verify secret if configured
if (env.WHATSAPP_WEBHOOK_SECRET) {
const signature = payload.headers["x-hub-signature-256"];
// verification logic omitted for brevity
}
await handleIncomingWebhook(payload);
res.sendStatus(200);
}
@Get("health")
health() {
return { status: "ok", enabled: !!env.WHATSAPP_AI_ENABLED };
}
}
The controller is deliberately thin; all business logic lives in the service.
5. New WhatsApp AI service (apps/api/src/whatsapp-ai/whatsapp-ai.service.ts)
import { query as db } from "../db/db.js";
import { env } from "../common/env.js";
import { callGroqWithTools, getGroqKey } from "../shared/groq.helper.js";
// Tool definition for Groq
const propertyTool = {
type: "function",
function: {
name: "getPropertyById",
description: "\"Retrieve a real estate property by its internal ID\","
parameters: {
type: "object",
properties: {
id: { type: "string", description: "\"Property identifier\" },"
},
required: ["id"],
},
},
};
export async function handleIncomingWebhook(payload: any) {
const message = payload.message?.text?.body?.trim();
if (!message) return;
// Build prompt
const prompt = `You are a virtual broker assistant. Answer the user query using the getPropertyById function when appropriate.\nUser: ${message}`;
// Call Groq with function-calling enabled
const groqResponse = await callGroqWithTools(prompt, [propertyTool]);
// If Groq decided to call the function, we execute it
if (groqResponse.choices[0].message?.tool_calls) {
const toolCall = groqResponse.choices[0].message.tool_calls[0];
if (toolCall.function.name === "getPropertyById") {
const args = JSON.parse(toolCall.function.arguments);
const property = await db<{ id: string; title: "string; price: number }>`"
SELECT id, title, price FROM properties WHERE id = ${args.id}
`;
// Send response back to WhatsApp (simplified)
await sendWhatsAppMessage(payload.from, formatProperty(property));
}
} else {
// Fallback: plain text answer
const answer = groqResponse.choices[0].message.content;
await sendWhatsAppMessage(payload.from, answer);
}
}
// Helper to send a WhatsApp message via the provider API
async function sendWhatsAppMessage(to: string, text: string) {
// Implementation depends on the provider (Twilio, Meta Cloud API, etc.)
// Placeholder:
console.log(`Sending to ${to}: ${text}`);
}
// Simple formatter
function formatProperty(p: any): string {
return `🏡 ${p.title}\n💲 ${p.price.toLocaleString("en-US")}\nID: ${p.id}`;
}
Key points:
- Function‑calling: The service inspects `tool_calls
Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.
Repo: zaerohell/VS · 2026-08-13
#playadev #buildinpublic
Top comments (0)