SMB store owners field repetitive product questions from website visitors, but current chatbots are either generic or require heavy manual setup. The existing support burden grows with catalog size and seasonal traffic spikes.
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.
In this tutorial you’ll build a conversational AI agent that answers customer questions about products, inventory, and orders using live data from a Shopify store. The agent uses a hybrid retrieval-augmented generation (RAG) pipeline that combines vector search (via Qdrant + FastEmbed) with keyword search to find the most relevant products, then answers using Anthropic’s Claude. It maintains multi-turn conversations with DynamoDB-backed session storage via @reaatech/session-continuity, remembers user preferences across visits via @reaatech/agent-memory, and optionally traces interactions with Langfuse.
This is a Next.js App Router project with a complete chat UI, three API endpoints, and a full test suite.
Prerequisites
Node.js >= 22 and pnpm 10 installed
A Shopify store with Admin API access (you’ll need an API key, secret, store domain, and admin access token)
An Anthropic API key for Claude
A Qdrant instance (local via Docker at http://127.0.0.1:6333, or cloud)
An AWS account with DynamoDB access (or local DynamoDB) for session persistence
An OpenAI API key (required internally by @reaatech/agent-memory for memory extraction — separate from the Anthropic chat)
A Langfuse account (optional, for observability)
Familiarity with TypeScript, Next.js App Router, and basic RAG concepts
Step 1: Scaffold the project and install dependencies
Start with a fresh Next.js project. The scaffold agent has already created the project shell and installed dependencies. Here are the key fields from package.json:
Every dependency is pinned to an exact semver (no ^ or ~). The REAA packages (@reaatech/*) provide the building blocks for hybrid RAG, session continuity, and agent memory. The postinstall script runs a setup hook after dependencies are resolved.
After verifying the scaffold, run install to resolve dependencies:
terminal
pnpm install
Expected output: pnpm creates pnpm-lock.yaml and installs all dependencies into node_modules/ with no errors.
Step 2: Configure environment variables
Create a .env file in the project root. All values are read at runtime via process.env. Here’s the complete .env.example that ships with the project:
env
# Env vars used by anthropic-knowledge-agent-for-shopify-product-qa.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# Shopify Admin API credentialsSHOPIFY_API_KEY=<your-shopify-api-key>SHOPIFY_API_SECRET_KEY=<your-shopify-secret>SHOPIFY_STORE_DOMAIN=<your-store.myshopify.com>SHOPIFY_ACCESS_TOKEN=<your-shopify-admin-access-token># Anthropic Claude APIANTHROPIC_API_KEY=<your-anthropic-key># Qdrant vector storeQDRANT_URL=<http://127.0.0.1:6333>QDRANT_API_KEY=<your-qdrant-api-key># AWS DynamoDB (for session storage)AWS_REGION=<us-east-1>AWS_ACCESS_KEY_ID=<your-aws-access-key>AWS_SECRET_ACCESS_KEY=<your-aws-secret>DYNAMODB_TABLE_NAME=<sessions># OpenAI (required by @reaatech/agent-memory for internal memory extraction — separate from Anthropic)OPENAI_API_KEY=<your-openai-key># Langfuse observability (optional)LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>
Expected output: Review each variable. The QDRANT_URL defaults to http://127.0.0.1:6333 for local development. QDRANT_API_KEY is optional — omit it when using local Qdrant. LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY are both optional; if only one is set, Langfuse is disabled with a warning.
Step 3: Define TypeScript types
Create src/types/index.ts to define the data shapes used throughout the project. These types keep the Shopify product model, API request/response contracts, and sync status strongly typed.
Expected output:pnpm typecheck passes with no errors for this file.
Step 4: Create the Shopify product sync module
Create src/lib/shopify-sync.ts. This module connects to the Shopify Admin API, fetches all products with since_id-based pagination, and maps them to the ShopifyProduct type. It’s the data ingestion pipeline — you’ll call this when you need to populate the vector store.
ts
import '@shopify/shopify-api/adapters/node';import { shopifyApi, ApiVersion } from '@shopify/shopify-api';import type { ShopifyProduct } from '../types/index.js';export class ShopifySyncError extends Error { constructor(message: string, public status?: number) { super(message); this.name = 'ShopifySyncError'; }}interface RawProductVariant { id: number; title: string; price: string | null;
Expected output: The module exports createShopifyClient, buildAccessSession, and fetchAllProducts. Note the side-effect import @shopify/shopify-api/adapters/node at the top — this must load before shopifyApi is used.
Key details:
createShopifyClient() creates an API client configured for a custom app (no OAuth), using the ApiVersion.July25 version of the Shopify API.
buildAccessSession() constructs a minimal session object with the store domain and access token.
fetchAllProducts() paginates using since_id: it fetches pages until one returns zero items, then returns the full collection. Errors with HTTP status 401, 429, or 5xx are wrapped in ShopifySyncError.
Step 5: Create the FastEmbed embedding wrapper
Create src/lib/embeddings.ts. This module wraps FastEmbed’s FlagEmbedding model to generate 768-dimensional embeddings (BGE-Base-EN). You’ll use it to embed product descriptions for vector search and user queries at query time.
Expected output: Three exports — createEmbeddingModel (initializes the model), embedTexts (batched embedding for multiple texts, returns empty array for empty input), and embedQuery (single-query embedding).
The EmbedModel interface abstracts over FlagEmbedding’s async generator pattern: model.embed(texts, batchSize) yields batches of number[][] that you collect with for await. This is important for the RAG pipeline, which calls embedTexts with each product’s text chunks.
Step 6: Create the Qdrant vector store adapter
Create src/lib/vector-store.ts. This module wraps the Qdrant REST client to provide collection management, upsert, vector search, and keyword search operations.
Expected output: Five exports covering the full lifecycle — createQdrantClient, ensureCollection (creates if missing, with vector size 768 and Cosine distance), upsertChunks (skips empty arrays), searchVectors (clamps topK to minimum 1), and searchKeywords (each result gets a score of 1.0 since keyword matching is binary).
The ensureCollection function also creates a payload index on the content field with field_schema: 'text', which enables Qdrant’s built-in keyword search used by searchKeywords.
Step 7: Create the Anthropic Claude client
Create src/lib/anthropic-client.ts. This thin wrapper around @anthropic-ai/sdk handles the message creation API, extracts text from content blocks, and surfaces token usage.
ts
import Anthropic from '@anthropic-ai/sdk';export class AnthropicError extends Error { constructor(message: string, public status?: number) { super(message); this.name = 'AnthropicError'; }}export function createAnthropicClient(): Anthropic { return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });}export async function generateChatCompletion( client: Anthropic, params: { model: string; maxTokens: number; messages: Array<{ role: 'user' | 'assistant'; content: string }>; system?: string; },): Promise<{ text: string; inputTokens: number; outputTokens: number }> { if (params.maxTokens <= 0) { throw new AnthropicError('maxTokens must be positive', 400); } if (params.messages.length === 0) { throw new AnthropicError('messages array must not be empty', 400); } try { const message = await client.messages.create({ model: params.model, max_tokens: params.maxTokens, messages: params.messages, ...(params.system ? { system: params.system } : {}), }); const text = message.content[0]?.type === 'text' ? message.content[0].text : ''; return { text, inputTokens: message.usage.input_tokens, outputTokens: message.usage.output_tokens, }; } catch (error: unknown) { const err = error as { status?: number; message?: string }; throw new AnthropicError( err.message ?? 'Anthropic API error', err.status ?? 500, ); }}
Expected output: Note that @anthropic-ai/sdk uses a default export, not a named one — import Anthropic from '@anthropic-ai/sdk'. The generateChatCompletion function maps max_tokens (snake_case, the Anthropic API field) from the maxTokens (camelCase) parameter. When a system prompt is provided, it’s passed as a top-level system parameter, not as a system message inside the messages array.
Step 8: Build the RAG pipeline orchestrator
Create src/lib/rag-pipeline.ts. This is the central orchestrator that ties together embeddings, vector search, keyword search, and Claude to answer product questions.
ts
import { DocumentSchema } from '@reaatech/hybrid-rag';import type { ShopifyProduct } from '../types/index.js';import { createEmbeddingModel, embedTexts, embedQuery, EmbeddingError } from './embeddings.js';import { createQdrantClient, ensureCollection, upsertChunks, searchVectors, searchKeywords, VectorStoreError } from './vector-store.js';import { createAnthropicClient, generateChatCompletion, AnthropicError } from './anthropic-client.js';export class RagPipelineError extends Error { constructor(message: string) { super(message); this.name = 'RagPipelineError'; }}export class RagPipeline { private config
Expected output: The RagPipeline class has two main functions:
indexProducts(products) — For each product, it validates the shape with DocumentSchema.parse() from @reaatech/hybrid-rag, splits title + description into 300-character chunks, embeds each chunk, and upserts them to Qdrant with a payload containing productId, title, and content. Returns the total chunk count.
answerQuery(query, chatHistory) — Embeds the user query, runs both searchVectors and searchKeywords against Qdrant (each with topK=5), merges results by productId keeping the highest score, builds a system prompt from the top 5 products, appends the query to chat history, and calls Claude. Returns the answer text and the top 3 sources with product IDs and relevance scores.
The hybrid approach combines semantic similarity (vector search) with keyword matching — if a product description contains the user’s exact words, it’s boosted regardless of vector distance.
Step 9: Set up session management with DynamoDB
Create src/lib/session.ts. This wraps @reaatech/session-continuity with a DynamoDB storage backend to manage multi-turn conversations.
Expected output: The session configuration uses a token budget of 4096 tokens with 500 reserved. When the budget is exceeded, the overflowStrategy is 'compress' and compression uses a 'sliding_window' strategy targeting 3500 tokens. This means long conversations automatically trim older messages to stay within context limits.
The addMessageToSession function has a retry mechanism: if adding a message throws TokenBudgetExceededError, it compresses the session context and retries once before failing.
Step 10: Set up agent memory for user preferences
Create src/lib/memory.ts. This wraps @reaatech/agent-memory to extract and recall user preferences across visits. Note: agent memory uses OpenAI internally (separate from the Anthropic chat) for natural-language memory extraction.
Expected output: The createAgentMemory function uses OpenAILLMProvider — this is the only LLM provider exported by @reaatech/agent-memory. It’s used exclusively for extracting facts and preferences from conversation turns, not for answering customers. The extraction is configured to look for MemoryType.FACT and MemoryType.PREFERENCE with a confidence threshold of 0.7, meaning only high-confidence extractions are stored.
The storeConversation function gracefully handles failures — if extraction throws, it returns 0 rather than propagating the error, so a memory failure never breaks the chat experience.
Step 11: Add Langfuse observability
Create src/lib/observability.ts. This optional module traces chat interactions to Langfuse for monitoring and debugging.
ts
import { Langfuse } from 'langfuse';export function createLangfuse(): Langfuse | null { const publicKey = process.env.LANGFUSE_PUBLIC_KEY; const secretKey = process.env.LANGFUSE_SECRET_KEY; if (publicKey && secretKey) { return new Langfuse({ publicKey, secretKey, }); } if (publicKey || secretKey) { console.warn( 'Langfuse partially configured: both LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be set', ); } return null;}export function traceInteraction( langfuse: Langfuse | null, data: { sessionId: string; query: string; answer: string; model: string; inputTokens: number; outputTokens: number; },): void { if (!langfuse) { return; } const trace = langfuse.trace({ name: 'chat', sessionId: data.sessionId }); trace.generation({ name: 'claude-completion', model: data.model, input: data.query, output: data.answer, usage: { input: data.inputTokens, output: data.outputTokens }, });}
Expected output:createLangfuse returns null when either env var is missing (with a warning if only one is set). traceInteraction is a no-op when given null, so you can safely call it unconditionally from the chat route. The trace records the session ID, model name, query, answer, and token usage.
Step 12: Create the API route handlers
Now create the three API routes under app/api/.
app/api/chat/route.ts — The chat endpoint
This handles user questions. It loads session history, retrieves preferences, calls the RAG pipeline, persists the conversation turn, traces to Langfuse, and returns the answer with sources.
ts
import { type NextRequest, NextResponse } from 'next/server';import { z } from 'zod';import { createSessionManager, addMessageToSession, getSessionHistory, ChatError } from '../../../src/lib/session.js';import { createAgentMemory, storeConversation } from '../../../src/lib/memory.js';import { RagPipeline, RagPipelineError } from '../../../src/lib/rag-pipeline.js';import { createLangfuse, traceInteraction } from '../../../src/lib/observability.js';import { AnthropicError } from '../../../src/lib/anthropic-client.js';let manager: ReturnType<typeof createSessionManager> | undefined;function getManager(): ReturnType<
Expected output: The route uses singleton lazy initialization for the session manager, agent memory, RAG pipeline, and Langfuse client — each is created once and reused across requests. Request validation uses Zod: { sessionId: z.string().min(1), message: z.string().min(1), userId: z.string().optional() }. Error responses are typed: 400 for validation, 404 for missing session, 502 for LLM/retrieval failures, 500 for unexpected errors.
app/api/sessions/route.ts — Session management
This route provides CRUD for chat sessions: create (POST), read history (GET), and delete (DELETE).
Expected output: GET returns the current sync status ({ lastSyncAt, productCount, status }). POST triggers a full sync: it fetches all products from Shopify, indexes them into Qdrant, and returns { productCount, chunkCount, status: 'synced' }. A concurrent sync attempt returns 409. Shopify API failures return 502.
Step 13: Build the chat UI
Create the home page at app/page.tsx — it simply redirects to the chat page:
tsx
import { redirect } from 'next/navigation';export default function Home() { redirect('/chat');}
Create the chat page at app/chat/page.tsx — a client component with inline styles and no external UI library:
Expected output: On mount, the page creates a session via POST /api/sessions and stores the sessionId. When the user sends a message, it calls POST /api/chat with the session ID, renders the assistant’s answer, and shows source citations as a collapsible <details> element. The input is disabled while loading, and errors are displayed inline.
Step 14: Run the tests
The project ships with a comprehensive test suite. Run it to verify everything works:
terminal
pnpm test
Expected output: vitest runs all test files and produces a report. You should see output similar to:
The tests mock all external services (Shopify, Anthropic, Qdrant, DynamoDB, FastEmbed, OpenAI) via vi.mock so they run entirely offline. Each module has happy-path, error, and boundary tests. The route handler tests use NextRequest from next/server.js directly — no server startup needed.
You can also run the type checker and linter:
terminal
pnpm typecheckpnpm lint
Expected output: Both exit 0 with no errors or warnings.
Next steps
Add a sync scheduler — Set up a daily cron job via POST /api/sync to keep the product index fresh. Use next/cache revalidation or a Vercel Cron Job.
Support customer-specific pricing — Pass a Shopify customer ID from the chat session and display personalized prices using the Shopify Admin API’s customer context.
Track abandoned carts — Extend the agent to check for abandoned carts via the Shopify Orders API and proactively offer assistance.
Deploy to production — Set up a Qdrant Cloud instance, a managed DynamoDB table, and deploy to Vercel with proper environment variables.
inventory_quantity
:
number
|
null
;
sku: string | null;
}
interface RawProductImage {
src: string;
}
interface RawProduct {
id: number;
title: string;
body_html: string | null;
variants: RawProductVariant[];
product_type: string | null;
images: RawProductImage[];
updated_at: string;
}
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new ShopifySyncError(`Missing required env var: ${name}`, 500);
const productId = result.payload.productId as string;
const existing = merged.get(productId);
if (!existing || result.score > existing.score) {
merged.set(productId, {
productId,
productTitle: result.payload.title as string,
score: result.score,
content: result.payload.content as string,
});
}
}
for (const result of keywordResults) {
const productId = result.payload.productId as string;
const existing = merged.get(productId);
if (!existing || 1.0 > existing.score) {
merged.set(productId, {
productId,
productTitle: result.payload.title as string,
score: 1.0,
content: result.payload.content as string,
});
}
}
const sources = Array.from(merged.values())
.sort((a, b) => b.score - a.score)
.slice(0, 3);
const topProducts = Array.from(merged.values())
.sort((a, b) => b.score - a.score)
.slice(0, 5);
let systemPrompt = 'You are a helpful Shopify product assistant. Answer customer questions about products based on the following product information:\n\n';
systemPrompt += 'If you cannot find specific products matching the question, respond: "I couldn\'t find specific products matching your question. Could you rephrase or ask about a different product?"';