The marketing contractor at a $3M WooCommerce store relies on a fixed weekly email schedule, missing opportunities to re-engage shoppers who abandon carts or browse specific categories. Without behavioral triggers, they waste budget on irrelevant campaigns and fail to convert high-intent visitors. The contractor has no time to manually monitor analytics and set up complex automation rules. This results in lost revenue and a poor ROI on email marketing spend.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
Build a behavioral email trigger agent for WooCommerce that replaces calendar-based email schedules with real-time, LLM-driven decisions. When a shopper views a product, abandons a cart, or places an order, this agent classifies the event, stores it in long-term memory, decides which email to send, and enqueues it for delivery via Klaviyo. You’ll wire five REAA packages into a Next.js 16 App Router project, using Vercel AI SDK for LLM calls, Upstash QStash for durable queuing, and Langfuse for observability. This tutorial is for TypeScript developers familiar with Next.js who want to see how LLMs, agent memory, and email APIs combine into an ecommerce automation pipeline.
Prerequisites
Node.js >= 22 and pnpm 10 installed
An OpenAI API key (for LLM calls and embeddings)
A Klaviyo account with an API key
An Upstash account with QStash token and signing keys
(Optional) Langfuse public and secret keys for tracing
A WooCommerce webhook secret for HMAC verification
Step 1: Scaffold the project and install dependencies
Start from an empty directory. You’ll use the Next.js App Router with exact-pinned dependencies.
terminal
mkdir behavioral-email-trigger && cd behavioral-email-trigger
The scaffold includes a package.json with all dependencies already exact-pinned. Verify the key ones are in place:
terminal
cat
package.json
|
head
-30
Expected output: You’ll see dependencies including next@16.2.7, ai@6.0.197, @ai-sdk/openai@3.0.68, zod@4.4.3, langfuse@3.38.20, @upstash/qstash@2.11.0, klaviyo-api@22.0.1, and the five REAA packages (@reaatech/agent-memory@0.1.0, @reaatech/agent-memory-core@0.1.0, @reaatech/agent-memory-retrieval@0.1.0, @reaatech/webhook-relay-core@0.2.0, @reaatech/webhook-relay-tools@0.2.0).
Install everything:
terminal
pnpm install
terminal
pnpm typecheck
Expected output: TypeScript compiles without errors.
Step 2: Set up environment variables
Create an .env.example file with placeholders for every integration:
env
# Env vars used by agnostic-behavioral-email-trigger.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentOPENAI_API_KEY=<your-openai-key>KLAVIYO_API_KEY=<your-klaviyo-key>QSTASH_TOKEN=<your-qstash-token>QSTASH_CURRENT_SIGNING_KEY=<your-qstash-signing-key>QSTASH_NEXT_SIGNING_KEY=<your-qstash-next-signing-key>LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_BASE_URL=https://cloud.langfuse.comWOOCOMMERCE_WEBHOOK_SECRET=<webhook-secret>
Copy it to .env.local and fill in your real keys:
terminal
cp .env.example .env.local
Expected output: Your .env.local now holds the API keys this agent needs at runtime.
Step 3: Define the Zod-validated type layer
Create src/types/woocommerce.ts — schemas for the raw webhook payloads WooCommerce sends:
Expected output: Five lib modules under src/lib/ — LLM client, Klaviyo client, QStash client, Langfuse wrapper, and webhook validator.
Step 6: Build the behavior memory service
Create src/services/behavior-memory-service.ts. This is the heart of the recipe — it uses @reaatech/agent-memory to store behavioral events and @reaatech/agent-memory-retrieval to format memories for LLM prompts:
ts
import { AgentMemory, OpenAILLMProvider, MemoryType,} from "@reaatech/agent-memory";import { ContextInjector } from "@reaatech/agent-memory-retrieval";import { type Memory, type ConversationTurn, MemoryImportance,} from "@reaatech/agent-memory-core";import type { BehaviorEvent } from "../types/behavior-events.js";import { type BehaviorProfile, emptyProfile,} from "../types/memory-context.js";export class BehaviorMemoryService { private readonly agentMemory: AgentMemory; private readonly contextInjector: ContextInjector; constructor(apiKey: string) { this.agentMemory = new AgentMemory({ storage: { provider: "memory" }, embedding: { provider: "openai", model: "text-embedding-3-small", apiKey, }, extraction: { llmProvider: new OpenAILLMProvider({ apiKey, model: "gpt-4o-mini", }), enabledTypes: [ MemoryType.FACT, MemoryType.PREFERENCE, MemoryType.CORRECTION, ], batchSize: 10, confidenceThreshold: 0.7, }, }); this.contextInjector = new ContextInjector(); } async storeBehaviorEvent(event: BehaviorEvent): Promise<void> { const turns: ConversationTurn[] = [ { speaker: "user", content: JSON.stringify(event), timestamp: new Date(event.timestamp), }, ]; await this.agentMemory.extractAndStore(turns); } async retrieveCustomerProfile(email: string): Promise<BehaviorProfile> { const memories = await this.agentMemory.retrieve(email, { limit: 20 }); if (memories.length === 0) { return emptyProfile(email); } const profile: BehaviorProfile = { customerEmail: email, preferredCategories: [], abandonedCartCount: 0, avgOrderValue: 0, lastActiveAt: new Date(), totalPageViews: 0, inferredInterests: [], recentOrders: [], }; for (const mem of memories) { if (mem.type === MemoryType.PREFERENCE && mem.category) { profile.preferredCategories.push(mem.category); } if ( mem.importance === MemoryImportance.HIGH && mem.content.includes("order") ) { profile.abandonedCartCount++; } if (mem.lastAccessedAt > profile.lastActiveAt) { profile.lastActiveAt = mem.lastAccessedAt; } if (mem.content.includes("page_view")) { profile.totalPageViews++; } if (mem.category && !profile.inferredInterests.includes(mem.category)) { profile.inferredInterests.push(mem.category); } } profile.preferredCategories = [...new Set(profile.preferredCategories)]; return profile; } async formatMemoriesForLLM( memories: Memory[], tokenBudget: number, ): Promise<string> { return this.contextInjector.injectMemoriesIntoContext( [], memories, tokenBudget, ); } async runMaintenance(): Promise<void> { await this.agentMemory.runMaintenance(); }}
Expected output: A service that uses AgentMemory for storage and retrieval, plus ContextInjector for token-budgeted memory formatting.
Step 7: Build the classifier and decision services
Create src/services/behavior-classifier-service.ts — it uses the LLM to turn raw WooCommerce JSON into a structured BehaviorEvent:
ts
import type { LlmClient } from "../lib/llm.js";import { BehaviorEventSchema, type BehaviorEvent,} from "../types/behavior-events.js";export interface ClassificationResult { success: boolean; event?: BehaviorEvent; error?: string;}export class BehaviorClassifierService { private readonly llmClient: LlmClient; constructor(llmClient: LlmClient) { this.llmClient = llmClient; } async classifyEvent(rawPayload: unknown): Promise<ClassificationResult> { try { const systemPrompt = "You are a WooCommerce event classifier. Given a raw WooCommerce webhook payload, " + "determine the event type and extract structured data. Return a JSON object with " + "'type' (one of: page_view, product_view, cart_add, cart_remove, checkout_started, order_placed), " + "'customerEmail', and type-specific fields."; const { object } = await this.llmClient.generateStructured({ system: systemPrompt, prompt: JSON.stringify(rawPayload), schema: BehaviorEventSchema, }); const behaviorEvent = BehaviorEventSchema.parse(object); return { success: true, event: behaviorEvent }; } catch (error) { return { success: false, error: String(error), }; } }}
Create src/services/email-decision-service.ts — it takes a classified event plus the customer’s behavior profile and asks the LLM which email trigger type applies:
ts
import type { LlmClient } from "../lib/llm.js";import type { BehaviorEvent } from "../types/behavior-events.js";import { EmailDecisionSchema, type EmailDecision,} from "../types/email-templates.js";import type { BehaviorProfile } from "../types/memory-context.js";export interface DecisionResult { success: boolean; decision?: EmailDecision; error?: string;}const TRIGGER_RULES = "Rules:\n" + "1. cart_add or checkout_started with cartTotal > $100 and no recent order → abandoned_cart (priority 4-5)\n" + "2. product_view in a category the customer has purchased from before → category_reengagement (priority 3-4)\n" + "3. 5+ page_view events, no cart_add → browse_abandonment (priority 2-3)\n" + "4. Returning customer with 3+ orders and high AOV (>$75) → high_value_alert (priority 4)\n" + "5. New customer with first page_view → welcome_series (priority 1)\n" + "6. Recent order_placed → post_purchase_followup (priority 3)\n" + "Return a JSON with triggerType, customerEmail, templateId, personalizationVars, priority (1-5), and reason.";export class EmailDecisionService { private readonly llmClient: LlmClient; constructor(llmClient: LlmClient) { this.llmClient = llmClient; } async decide( event: BehaviorEvent, profile: BehaviorProfile, ): Promise<DecisionResult> { try { const systemPrompt = "You are an email trigger decision engine for WooCommerce. " + "Given a customer behavior event and their profile, determine the best email to send.\n" + TRIGGER_RULES; const prompt = JSON.stringify({ event, profile }); const { object } = await this.llmClient.generateStructured({ system: systemPrompt, prompt, schema: EmailDecisionSchema, }); const decision = EmailDecisionSchema.parse(object); return { success: true, decision }; } catch (error) { return { success: false, error: String(error), }; } }}
Expected output: Two services that chain LLM calls — one to classify raw data, another to decide which email to send.
Step 8: Build the webhook ingestion service
Create src/services/webhook-ingestion-service.ts. This is the orchestration hub that verifies signatures, classifies events, stores in memory, and enqueues to QStash — all with deduplication:
Expected output: A centralized ingestion service that chains signature verification → classification → memory → queue in the correct order, handling duplicates within a 5-minute TTL.
Step 9: Build the trigger executor and MCP service
Create src/services/trigger-executor-service.ts. It consumes an EmailDecision and calls Klaviyo, storing a confirmation memory on success:
export { startWebhookMCPServer } from "./services/mcp-service.js";export { BehaviorMemoryService } from "./services/behavior-memory-service.js";export { BehaviorClassifierService } from "./services/behavior-classifier-service.js";export { EmailDecisionService } from "./services/email-decision-service.js";export { WebhookIngestionService } from "./services/webhook-ingestion-service.js";export { TriggerExecutorService } from "./services/trigger-executor-service.js";export { LlmClient } from "./lib/llm.js";export { KlaviyoClient } from "./lib/klaviyo.js";export { QstashClient } from "./lib/qstash.js";export { parseConfig } from "./lib/config.js";export { initLangfuse, getLangfuse, createTrace } from "./lib/langfuse.js";export { verifyWooCommerceSignature } from "./lib/webhook-validator.js";export type { BehaviorEvent } from "./types/behavior-events.js";export type { EmailDecision, TriggerResult } from "./types/email-templates.js";export type { BehaviorProfile, RecentOrder } from "./types/memory-context.js";
Step 12: Write tests
Start with the test infrastructure. Create tests/setup.ts to configure MSW:
ts
import { beforeAll, afterEach, afterAll } from "vitest";import { setupServer } from "msw/node";import { handlers } from "./mocks/msw-handlers.js";const msw = setupServer(...handlers);function listenMsw(): void { msw.listen({ onUnhandledRequest: "error" } as Record<string, string>);}function resetMsw(): void { msw.resetHandlers();}function closeMsw(): void { msw.close();}beforeAll(listenMsw);afterEach(resetMsw);afterAll(closeMsw);
Create tests/mocks/msw-handlers.ts to mock OpenAI, Klaviyo, QStash, and Langfuse:
Now create the integration test at tests/integration/behavior-email-flow.test.ts:
ts
import { describe, it, expect, vi } from "vitest";import { mockOrderPlacedPayload } from "../fixtures/woocommerce.js";import { mockCartAddEvent, mockProductViewEvent } from "../fixtures/behavior-events.js";import { mockEmailDecision } from "../fixtures/email-decisions.js";import type { BehaviorProfile } from "../../src/types/memory-context.js";const mockLlmGenerate = vi.fn();const mockLlmGenerateStructured = vi.fn();const mockKlaviyoSend = vi.fn();const mockQstashEnqueue = vi.fn();const mockStoreBehavior = vi.fn
Expected output: A test suite with MSW mocks, fixture factories, and integration tests covering the happy path, abandoned cart decisions, category re-engagement, Klaviyo failures, and deduplication.
Step 13: Run the tests
terminal
pnpm typecheckpnpm lintpnpm test
Expected output: TypeScript compiles with 0 errors, lint passes with 0 errors, and vitest reports all tests passing with coverage thresholds met (>=90% on lines, branches, functions, statements for runtime code under src/ and app/**/route.ts).
Next steps
Add an outbound email processing worker that reads from the QStash queue and calls TriggerExecutorService.execute() — currently the queue is written to but not consumed by this recipe, which focuses on the webhook ingestion side.
Replace the in-memory memory store with a persistent backend so customer profiles survive server restarts.
Extend the landing page with a real-time dashboard showing recent webhook events, active customer profiles, and email trigger decisions using Langfuse trace data.