eBay sellers often lose sales because they can't respond to buyer inquiries about order status, shipping, or product details fast enough, especially during high-traffic events.
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.
This tutorial walks you through building a knowledge agent that helps eBay sellers answer buyer questions by pulling live data from their seller account and augmenting it with semantic search over their listings and FAQs. You’ll connect Google Gemini (Vertex AI) for generation, Voyage AI for embeddings, pgvector on Neon PostgreSQL for vector search, and five REAA packages for session continuity, context planning, agent memory, LLM caching, and structured output repair.
Prerequisites
Node.js 22+ and pnpm 10+
A Google Cloud project with the Vertex AI API enabled and a service account key for Gemini
A Neon PostgreSQL database (Postgres 15+ with pgvector extension)
A Voyage AI API key for embeddings
An eBay developer account with API keys and OAuth token (Buy, Sell, and Inventory API scopes)
A Langfuse account for observability (optional — the agent works without it)
Step 1: Scaffold the Next.js project
Start a fresh Next.js App Router project with TypeScript.
Expected output: No warnings about missing peer deps. Verify no ^ or ~ prefixes remain in package.json with grep -n '"[~^>]' package.json — it should return nothing.
Step 2: Configure environment variables
Create .env.example with all the variables the agent needs:
Copy it to .env.local and fill in your real values:
terminal
cp .env.example .env.local
Expected output: Two files — .env.example with placeholders (safe to commit) and .env.local with your real keys (gitignored by default).
Step 3: Create the database migration
Create src/migrations/001_setup.sql to set up pgvector and the tables:
sql
CREATE EXTENSION IF NOT EXISTS vector;CREATE TABLE IF NOT EXISTS listings ( id bigserial PRIMARY KEY, ebay_item_id text UNIQUE, title text, description text, category text, price numeric, embedding vector(1536), metadata jsonb, created_at timestamptz DEFAULT now());CREATE TABLE IF NOT EXISTS faqs ( id bigserial PRIMARY KEY, question text, answer text, category text, embedding vector(1536), created_at timestamptz DEFAULT now());CREATE INDEX IF NOT EXISTS idx_listings_embedding ON listings USING hnsw (embedding vector_cosine_ops);CREATE INDEX IF NOT EXISTS idx_faqs_embedding ON faqs USING hnsw (embedding vector_cosine_ops);
Expected output: A .sql file in the src/migrations/ directory. You’ll run this against your Neon database later.
Step 4: Define TypeScript types
Create the type definitions for eBay data, chat requests, and the agent’s knowledge sources.
export type { EbayItem, EbayOrder } from "./ebay.js";export { EbayApiError } from "./ebay.js";export type { ChatRequest, ChatResponse } from "./chat.js";export { ChatRequestSchema } from "./chat.js";export type { AgentToolCall, AgentToolResult } from "./agent.js";export { type KnowledgeSource } from "./agent.js";
Expected output: Four files under src/types/. Notice the .js extension in imports — TypeScript with moduleResolution: "nodenext" requires it even for .ts files.
Step 5: Build the database client
Create src/lib/neon.ts to wrap Neon’s serverless SQL client with pgvector for similarity search:
ts
import { neon } from "@neondatabase/serverless";import pgvector from "pgvector";interface DbRow { [key: string]: unknown;}const sql = neon(process.env.DATABASE_URL ?? "");export { sql };export async function searchListings(embedding: number[], limit = 5): Promise<DbRow[]> { const rows = await sql` SELECT * FROM listings ORDER BY embedding <-> ${pgvector.toSql(embedding)}::vector LIMIT ${limit} `; return rows as DbRow[];}export async function searchFaqs(embedding: number[], limit = 5): Promise<DbRow[]> { const rows = await sql` SELECT * FROM faqs ORDER BY embedding <-> ${pgvector.toSql(embedding)}::vector LIMIT ${limit} `; return rows as DbRow[];}export async function runMigrations() { const fs = await import("fs/promises"); const path = await import("path"); const dir = path.resolve(process.cwd(), "src/migrations"); const files = await fs.readdir(dir); const sqlFiles = files.filter((f) => f.endsWith(".sql")).sort(); for (const file of sqlFiles) { const content = await fs.readFile(path.join(dir, file), "utf-8"); await sql`${sql.unsafe(content)}`; }}
Expected output: A module that exports sql for raw queries, searchListings and searchFaqs for cosine-similarity vector search, and runMigrations for bootstrapping.
Step 6: Build the eBay API client
Create src/lib/ebay.ts with a class that calls the eBay Buy, Sell, and Inventory APIs with exponential-backoff retry:
Expected output: A reusable client with three methods — getItem, getOrders, and getListings — plus automatic retry with 1s/2s/4s backoff on rate limits and server errors.
Step 7: Create the embedding service
Create src/services/embedding.ts using the Voyage AI client:
ts
import { VoyageAIClient, VoyageAIError } from "voyageai";import type { EmbedResponseDataItem } from "voyageai";function extractEmbedding(item: EmbedResponseDataItem): number[] { if (!item.embedding) { throw new VoyageAIError({ message: "Embedding is null or undefined" }); } return item.embedding;}export class EmbeddingService { private client: VoyageAIClient; constructor(apiKey: string) { this.client = new VoyageAIClient({ apiKey }); } async embed(text: string): Promise<number[]> { const response = await this.client.embed({ input: text, model: "voyage-3-lite" }); if (!response.data || response.data.length === 0) { throw new VoyageAIError({ message: "No embedding data returned" }); } return extractEmbedding(response.data[0]); } async embedBatch(texts: string[]): Promise<number[][]> { const response = await this.client.embed({ input: texts, model: "voyage-3-lite" }); if (!response.data) { throw new VoyageAIError({ message: "No embedding data returned" }); } return response.data.map(extractEmbedding); } cosineSimilarity(a: number[], b: number[]): number { let dotProduct = 0; let normA = 0; let normB = 0; for (let i = 0; i < a.length; i++) { dotProduct += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } const magnitude = Math.sqrt(normA) * Math.sqrt(normB); if (magnitude === 0) return 0; return dotProduct / magnitude; }}export { VoyageAIError };
Expected output: An EmbeddingService class that converts text to 1024-dimension vectors using Voyage’s voyage-3-lite model, with batch embedding and a cosine-similarity utility.
Step 8: Wire up session continuity
Create src/services/session.ts that uses @reaatech/session-continuity to manage multi-turn conversations:
ts
import { SessionManager, type IStorageAdapter, type TokenCounter, type Session, type Message, SessionNotFoundError, TokenBudgetExceededError,} from "@reaatech/session-continuity";class InMemoryStorageAdapter implements IStorageAdapter { private sessions: Map<string, Session> = new Map(); private messages: Map<string, Map<string, Message>> = new Map(); private messageSequences: Map
Expected output: A session service with createSessionManager, getOrCreateSession, addChatMessage, and endSession. The SessionManager is configured with a 4096-token budget, 500-token reserve, and sliding-window compression.
Step 9: Create the context window planner
Create src/services/context.ts with @reaatech/context-window-planner to pack conversation turns, RAG chunks, and system prompts into the token budget:
Expected output: A packAgentContext function that builds a ContextPlanner with a priority-greedy strategy, adds the system prompt, conversation turns, and RAG chunks by relevance, then returns a PackingResult with what fit and what was dropped.
Step 10: Implement agent memory
Create src/services/memory.ts with @reaatech/agent-memory. This stores conversation facts and preferences and retrieves them across sessions:
ts
import { AgentMemory, MemoryType, type Memory, type ConversationTurn, type EmbeddingProvider,} from "@reaatech/agent-memory";import { VoyageAIClient, VoyageAIError } from "voyageai";import type { EmbedResponseDataItem } from "voyageai";function extractEmbedding(item: EmbedResponseDataItem): number[] { if (!item.embedding) { throw new VoyageAIError({ message: "Embedding is null or undefined" }); } return item.embedding;}export class VoyageEmbeddingProvider
Expected output: Three adapters — VoyageEmbeddingProvider (wraps Voyage AI as an EmbeddingProvider), GeminiLLMProvider (wraps Gemini as the memory extraction LLM), and a createAgentMemory factory that wires them together. The memory extracts facts and preferences from conversations with 70% confidence threshold.
Step 11: Add LLM caching
Create src/services/cache.ts with @reaatech/llm-cache to avoid duplicate Gemini calls:
Expected output: A cache engine that stores Gemini responses keyed by prompt. It supports TTL-based expiry (default 1 hour) and semantic matching via cosine similarity (threshold 0.85).
Step 12: Create the structured output repair service
Create src/services/repair.ts with @reaatech/structured-repair-core to parse and fix Gemini’s JSON output into a typed schema:
Expected output: A repairGeminiOutput function that takes raw Gemini output, passes it through repair(), and returns a typed object with answer, sources, and optional confidence. If the output can’t be repaired, a safe fallback is returned.
Step 13: Create the Gemini LLM service
Create src/services/llm.ts to talk to Google Gemini via @google/genai:
ts
import { GoogleGenAI } from "@google/genai";export function createGeminiClient(): GoogleGenAI { return new GoogleGenAI({ enterprise: true, project: process.env.GOOGLE_CLOUD_PROJECT, location: process.env.GOOGLE_CLOUD_LOCATION, });}export class GeminiApiError extends Error { constructor( message: string, public readonly status?: number, ) { super(message); this.name = "GeminiApiError"; }}export async function generateAnswer( ai: GoogleGenAI, model: string, prompt: string, contextItems?: string,): Promise<string | null | undefined> { try { const fullPrompt = contextItems ? `${contextItems}\n\n${prompt}` : prompt; const response = await ai.models.generateContent({ model, contents: fullPrompt, config: { systemInstruction: "You are a helpful eBay seller assistant. Answer buyer questions about orders, listings, shipping, and policies using the provided context. Be concise and accurate.", }, }); return response.text; } catch (err: unknown) { const error = err as { message?: string; status?: number }; throw new GeminiApiError( error.message ?? "Gemini API error", error.status, ); }}export async function generateStreamingAnswer( ai: GoogleGenAI, model: string, prompt: string,) { const stream = await ai.models.generateContentStream({ model, contents: prompt, }); return stream;}
Expected output: A createGeminiClient factory for Vertex AI, a generateAnswer function with system prompt and error wrapping, and a streaming variant.
Step 14: Set up Langfuse observability
Create src/services/observability.ts for tracing chat interactions:
Expected output: An optional observability layer that initializes only when LANGFUSE_PUBLIC_KEY is set, creates traces per-session, and records Gemini generation spans.
Step 15: Build the agent orchestrator
Now the main piece — src/services/agent.ts. This wires all the services together into a processMessage pipeline:
ts
import { EbayApiClient } from "../lib/ebay.js";import { searchListings, searchFaqs } from "../lib/neon.js";import { EmbeddingService } from "./embedding.js";import { createSessionManager, getOrCreateSession, addChatMessage, endSession } from "./session.js";import { createAgentMemory, retrieveMemories, storeMemories, shutdownMemory } from "./memory.js";import { createCacheEngine, checkCache, storeCache } from "./cache.js";import { createGeminiClient, generateAnswer } from "./llm.js";import { repairGeminiOutput } from "./repair.js";import { initLangfuse, traceChat, flush } from "./observability.js";import { packAgentContext } from "./context.js";import { VoyageAIClient }
Expected output: A single AgentOrchestrator class that runs the full pipeline: cache check → session management → embedding → memory retrieval → RAG → eBay API calls → context packing → Gemini generation → output repair → memory storage → cache write → observability trace.
Step 16: Create the package barrel export
Create src/index.ts as the public entry point for consumers who install your package:
ts
export * from "./types/index.js";export { EbayApiClient } from "./lib/ebay.js";export { sql, searchListings, searchFaqs, runMigrations } from "./lib/neon.js";
Expected output: A barrel file that re-exports types, the eBay client, and the database utilities so consumers can import from a single path.
Step 17: Create the chat API route
Create app/api/chat/route.ts — the HTTP entry point with POST, GET, and DELETE handlers:
ts
import { type NextRequest, NextResponse } from "next/server";import { AgentOrchestrator } from "../../../src/services/agent.js";import { z } from "zod";const ChatRequestSchema = z.object({ sessionId: z.string().optional(), message: z.string().min(1, "Message is required"),});const agent = new AgentOrchestrator();export async function POST(req: NextRequest) { try { const body: unknown = await req.json(); const parsed = ChatRequestSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid request", issues: parsed.error.issues }, { status: 400 }, ); } const { sessionId, message } = parsed.data; const result = await agent.processMessage(sessionId, message); return NextResponse.json(result); } catch { return NextResponse.json({ error: "Internal server error" }, { status: 500 }); }}export async function GET(req: NextRequest) { try { const sessionId = new URL(req.url).searchParams.get("sessionId"); if (!sessionId) { return NextResponse.json({ error: "sessionId is required" }, { status: 400 }); } const messages = await agent.getSessionHistory(sessionId); return NextResponse.json({ messages }); } catch { return NextResponse.json({ error: "Internal server error" }, { status: 500 }); }}export async function DELETE(req: NextRequest) { try { const sessionId = new URL(req.url).searchParams.get("sessionId"); if (!sessionId) { return NextResponse.json({ error: "sessionId is required" }, { status: 400 }); } await agent.endConversation(sessionId); return NextResponse.json({ ok: true }); } catch { return NextResponse.json({ error: "Internal server error" }, { status: 500 }); }}
Expected output: Three route handlers — POST to ask a question, GET to retrieve conversation history, and DELETE to end a session. All use NextRequest/NextResponse (never bare Request/Response) and return NextResponse.json() for proper content-type headers.
Step 18: Run the quality gates
Verify everything compiles, lints, and passes tests:
terminal
pnpm typecheckpnpm lintpnpm test
Expected output:pnpm typecheck exits 0 with no errors. pnpm lint exits 0 with no violations. pnpm test reports numFailedTests: 0 and coverage lines/branches/functions/statements all at or above 90%.
The test suite mocks all external services — Voyage AI, eBay APIs, Gemini, Langfuse — so you can run it offline. Here’s a snapshot of the agent orchestrator test:
ts
import { describe, it, expect, vi, beforeEach } from "vitest";import type { ChatResponse } from "../../src/types/chat.js";vi.mock("../../src/services/agent.js", async () => { process.env.EBAY_API_KEY = "test-api-key"; process.env.EBAY_OAUTH_TOKEN = "test-oauth-token"; process.env.VOYAGE_API_KEY = "test-voyage-key"; return await vi.importActual("../../src/services/agent.js");});import { AgentOrchestrator } from "../../src/services/agent.js";describe("AgentOrchestrator", () => { let agent: AgentOrchestrator; beforeEach(async () => { vi.clearAllMocks(); const cacheModule = await import("../../src/services/cache.js"); (cacheModule.checkCache as ReturnType<typeof vi.fn>).mockResolvedValue({ hit: false, reason: "not_found", }); agent = new AgentOrchestrator(); }); it("processMessage returns ChatResponse with sessionId and response", async () => { const result: ChatResponse = await agent.processMessage("session-1", "Where is my order?"); expect(result.sessionId).toBe("session-1"); expect(result.response).toBe("Your order is on its way!"); expect(result.sources).toHaveLength(1); expect(result.sources[0].type).toBe("order"); }); it("cache hit returns cached response without calling generateContent", async () => { const cacheModule = await import("../../src/services/cache.js"); const mockCheck = cacheModule.checkCache as ReturnType<typeof vi.fn>; mockCheck.mockResolvedValue({ hit: true, type: "exact", entry: { response: { response: "Cached response", sources: [] } }, cachedAt: new Date(), age: 0, }); const llmModule = await import("../../src/services/llm.js"); const mockGenerate = llmModule.generateAnswer as ReturnType<typeof vi.fn>; const result = await agent.processMessage("session-1", "Where is my order?"); expect(result.response).toBe("Cached response"); expect(mockGenerate).not.toHaveBeenCalled(); });});
To start the dev server and test the API:
terminal
pnpm dev
Then in another terminal:
terminal
# Start a conversationcurl -X POST http://localhost:3000/api/chat \ -H "Content-Type: application/json" \ -d '{"message": "Where is order 123?"}'# Continue an existing conversationcurl -X POST http://localhost:3000/api/chat \ -H "Content-Type: application/json" \ -d '{"sessionId": "<id-from-above>", "message": "When will it arrive?"}'# View conversation historycurl "http://localhost:3000/api/chat?sessionId=<session-id>"# End the sessioncurl -X DELETE "http://localhost:3000/api/chat?sessionId=<session-id>"
Expected output: The first POST returns a JSON response with sessionId, response, and sources. Subsequent calls with the same sessionId continue the same conversation context. The agent caches duplicate questions, retrieves memories from prior turns, and logs everything to Langfuse.
Next steps
Add thread support — wire up Langfuse thread IDs to group multi-turn conversations into a single trace view
Production storage — replace InMemoryStorageAdapter and InMemoryAdapter (cache) with Postgres or Redis-backed adapters for persistence across restarts
Streaming UI — use generateStreamingAnswer and Server-Sent Events to stream Gemini’s response character-by-character in a chat UI
Webhook notifications — add a webhook endpoint that eBay calls when an order status changes, automatically triggering a cached answer update
const systemPrompt = "You are a helpful eBay seller assistant. Answer buyer questions about orders, listings, shipping, and policies using the provided context. Be concise and accurate.";