Your customer-support agent treats every conversation as new — customers re-explain their account setup every time, the agent forgets what was tried in the last session, and there's no learning across interactions. You need durable per-customer memory: prior tickets, preferences, open issues, and free-form notes the agent can write back, all bounded by a token budget so context windows stay clean.
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 builds a customer-support agent that remembers every conversation. Powered by @reaatech/session-continuity and Anthropic Claude, the agent stores per-customer sessions, enforces a token budget so context windows stay clean, and auto-compresses older messages when they exceed the limit. By the end you’ll have a Next.js chat UI backed by API routes that create, resume, and query persistent sessions — plus a full test suite.
Prerequisites
Node.js 22+ and pnpm 10+
An Anthropic API key (set ANTHROPIC_API_KEY when you run the app)
Basic knowledge of TypeScript and Next.js App Router
(Optional) A Redis instance or AWS DynamoDB table for production storage — the app also works with mocks during development
Step 1: Create the Next.js project and install dependencies
Create a new Next.js project with the App Router and install all the dependencies.
Expected output: Your package.json lists all dependencies above with exact semver strings. The project compiles with pnpm typecheck (which may report no files to check at first).
Step 2: Configure environment variables
Create a .env.example file at the project root with the environment variables the application reads.
env
# Env vars used by anthropic-persistent-customer-memory-across-support-sessions.# Keep placeholders only — never commit real values.ANTHROPIC_API_KEY=<your-anthropic-key>REDIS_URL=<your-redis-url>AWS_REGION=<your-aws-region>AWS_ACCESS_KEY_ID=<your-access-key>AWS_SECRET_ACCESS_KEY=<your-secret>DYNAMODB_TABLE_NAME=sessionsSESSION_TTL_HOURS=24TOKEN_BUDGET_MAX=4096TOKEN_BUDGET_RESERVE=500COMPRESSION_STRATEGY=sliding_windowNODE_ENV=development
Then copy it to .env.local and fill in your Anthropic API key.
terminal
cp .env.example .env.local
Expected output: The file .env.local exists with ANTHROPIC_API_KEY set to a real key. The other vars can stay as placeholders until you configure Redis or DynamoDB.
Step 3: Define TypeScript types with Zod schemas
Create src/types.ts with the core data types and Zod validation schemas that the rest of the application will use.
Expected output: Running loadConfig() with ANTHROPIC_API_KEY and at least one storage backend configured returns a typed config object. Without the key it throws a descriptive error.
Step 5: Build the storage factory
Create src/services/storage-factory.ts. This factory inspects environment variables and creates the appropriate storage adapter — Redis or DynamoDB.
ts
import { type IStorageAdapter, type CompressionConfig,} from "@reaatech/session-continuity";import { RedisAdapter } from "@reaatech/session-continuity-storage-redis";import { DynamoDBAdapter } from "@reaatech/session-continuity-storage-dynamodb";import { DynamoDBClient } from "@aws-sdk/client-dynamodb";import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";import { createClient } from "redis";import { SESSION_TTL, DYNAMODB_TABLE_NAME } from "../constants.js";export async function createStorageAdapter(): Promise<IStorageAdapter> { const redisUrl = process.env.REDIS_URL; if (redisUrl) { const client = createClient({ url: redisUrl }); await client.connect(); return new RedisAdapter({ client: client as never, ttlSeconds: SESSION_TTL }); } const awsRegion = process.env.AWS_REGION; const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID; const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; if (awsRegion && awsAccessKeyId && awsSecretAccessKey) { const ddbClient = DynamoDBDocumentClient.from( new DynamoDBClient({ region: awsRegion, }), ); return new DynamoDBAdapter({ client: ddbClient, tableName: process.env.DYNAMODB_TABLE_NAME || DYNAMODB_TABLE_NAME, }); } throw new Error("No storage configured");}export function createDefaultCompressionConfig(): CompressionConfig { return { strategy: "sliding_window", targetTokens: 3500, minMessages: 5, };}
Notice the priority: when both REDIS_URL and AWS credentials are set, Redis wins. This lets you default to Redis in development and switch to DynamoDB in production by changing environment variables.
Expected output: Calling createStorageAdapter() with REDIS_URL set returns a RedisAdapter. With AWS vars set, it returns a DynamoDBAdapter. With neither set, it throws.
Step 6: Create the session store wrapper
Create src/services/session-store.ts. This module initializes a singleton SessionManager from the @reaatech/session-continuity package and exports helper functions that the rest of the app calls.
ts
import { SessionManager, SessionNotFoundError, type IStorageAdapter, type Session,} from "@reaatech/session-continuity";import { TokenizerFactory } from "@reaatech/session-continuity-tokenizers";import { SESSION_TTL, DEFAULT_TOKEN_BUDGET } from "../constants.js";import { createDefaultCompressionConfig } from "./storage-factory.js";let manager: SessionManager | null = null;export { SessionNotFoundError };export function createSessionStore(storage: IStorageAdapter): void { const tokenCounter = TokenizerFactory.create("claude-sonnet-4-6"); manager = new SessionManager({ storage, tokenCounter, tokenBudget: DEFAULT_TOKEN_BUDGET, compression: createDefaultCompressionConfig(), sessionTTL: SESSION_TTL, });}function getManager(): SessionManager { if (!manager) { throw new Error( "Session store not initialized. Call createSessionStore(storage) first.", ); } return manager;}export async function createSession( customerId: string,): Promise<Session> { const mgr = getManager(); return mgr.createSession({ userId: customerId, metadata: { title: `Support session for ${customerId}`, tags: ["support"], }, });}export async function getSession(sessionId: string): Promise<Session> { const mgr = getManager(); return mgr.getSession(sessionId);}export async function addMessage( sessionId: string, role: "user" | "assistant", content: string,) { const mgr = getManager(); return mgr.addMessage(sessionId, { role, content });}export async function getConversationContext(sessionId: string) { const mgr = getManager(); return mgr.getConversationContext(sessionId);}export async function getConversationContextWithStats(sessionId: string) { const mgr = getManager(); return mgr.getConversationContextWithStats(sessionId);}export async function listSessions( customerId: string,): Promise<Session[]> { const mgr = getManager(); return mgr.listSessions({ userId: customerId });}export async function endSession(sessionId: string): Promise<void> { const mgr = getManager(); return mgr.endSession(sessionId);}export async function deleteSession(sessionId: string): Promise<void> { const mgr = getManager(); return mgr.deleteSession(sessionId);}
The TokenizerFactory.create("claude-sonnet-4-6") auto-detects that claude- is an Anthropic model prefix and instantiates the right tokenizer. The SessionManager handles budget enforcement and compression automatically.
Expected output: After createSessionStore(storage) is called, createSession("cust-1") returns a session with userId: "cust-1" and status: "active".
Step 7: Build the Anthropic client
Create src/services/anthropic-client.ts. This class wraps the Anthropic SDK and adds a sendMessageWithSessionContext method that loads conversation history from the session store before calling Claude.
The key detail: sendMessageWithSessionContext stores the user’s message first, then loads context (which applies budget-aware compression automatically), sends the conversation to Claude, and stores Claude’s response. This way every exchange is persisted and token-budgeted.
Step 8: Create the memory service and session adapter
Create src/services/memory-service.ts. This orchestrator ties the session store and the Anthropic client together into a high-level processMessage method.
Expected output:MemoryService.processMessage({ customerId: "cust-1", message: "I need help" }) creates a session, sends the message to Claude, stores the assistant response, and returns { sessionId, reply, tokenUsage }.
Step 9: Wire up the API routes
Create the API routes under app/api/. Start with the chat endpoint at app/api/chat/route.ts.
ts
import { type NextRequest, NextResponse } from "next/server";import { ChatRequestSchema } from "../../../src/types.js";import { MemoryService } from "../../../src/services/memory-service.js";import { AnthropicClient } from "../../../src/services/anthropic-client.js";import { createSessionAdapter } from "../../../src/services/create-session-adapter.js";const anthropicClient = new AnthropicClient();const memoryService = new MemoryService(createSessionAdapter(), anthropicClient);export async function POST(request: NextRequest) { try { const body: unknown = await request.json(); const parsed = ChatRequestSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Validation failed", details: parsed.error.issues }, { status: 400 }, ); } const { customerId, message, sessionId, model } = parsed.data; const result = await memoryService.processMessage({ customerId, message, sessionId, model, }); return NextResponse.json(result, { status: 200 }); } catch (error) { const message = error instanceof Error ? error.message : "Internal server error"; return NextResponse.json({ error: message }, { status: 500 }); }}
Next, create the sessions list endpoint at app/api/sessions/route.ts.
Update the layout file at app/layout.tsx to set the proper metadata.
tsx
import type { Metadata } from "next";import { Geist, Geist_Mono } from "next/font/google";import "./globals.css";const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"],});const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"],});export const metadata: Metadata = { title: "Customer Memory — Support Agent", description: "Persistent customer memory across support sessions. Built with @reaatech/session-continuity and Anthropic Claude.",};export default function RootLayout({ children,}: Readonly<{ children: React.ReactNode;}>) { return ( <html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}> <body>{children}</body> </html> );}
Now create the module entry point at src/index.ts to re-export the public API.
ts
export { loadConfig } from "./services/config.js";export { createStorageAdapter, createDefaultCompressionConfig } from "./services/storage-factory.js";export { createSessionStore, createSession, getSession, addMessage, getConversationContext, getConversationContextWithStats, listSessions, endSession, deleteSession, SessionNotFoundError,} from "./services/session-store.js";export { AnthropicClient } from "./services/anthropic-client.js";export { MemoryService } from "./services/memory-service.js";export type { CustomerProfile, ChatRequest, ChatResponse, MemoryEntry, SessionSummary, SessionConfig,} from "./types.js";export { ChatRequestSchema, ChatResponseSchema, SessionConfigSchema } from "./types.js";
Expected output: Running pnpm dev starts the Next.js dev server. Navigating to http://localhost:3000 shows the chat UI with an input field, a Send button, and a New Session button.
Step 11: Run the tests
The test suite covers every service, endpoint, and boundary case — including token budget overflow, missing env vars, non-string content, and session-not-found errors. Tests mock the Anthropic API with MSW (Mock Service Worker) so no real API calls happen during test runs.
First, create vitest.config.ts at the project root to configure the test runner and coverage thresholds.
Create the config validation test at tests/services/config.test.ts.
ts
import { describe, it, expect, beforeEach, vi } from 'vitest';import { loadConfig } from '../../src/services/config.js';beforeEach(() => { vi.unstubAllEnvs();});describe('loadConfig', () => { it('returns config when all env vars are set', () => { vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-123'); vi.stubEnv('REDIS_URL', 'redis://localhost:6379'); vi.stubEnv('AWS_REGION', 'us-east-1'); vi.stubEnv('AWS_ACCESS_KEY_ID', 'akid'); vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'sak'); const config = loadConfig(); expect(config.anthropicApiKey).toBe('sk-ant-123'); expect(config.redisUrl).toBe('redis://localhost:6379'); }); it('throws when ANTHROPIC_API_KEY is missing', () => { vi.stubEnv('REDIS_URL', 'redis://localhost:6379'); expect(() => loadConfig()).toThrow(/ANTHROPIC_API_KEY/); }); it('uses defaults for optional env vars when not set', () => { vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-123'); vi.stubEnv('REDIS_URL', 'redis://localhost:6379'); const config = loadConfig(); expect(config.sessionTTLHours).toBe(24); expect(config.tokenBudgetMax).toBe(4096); expect(config.tokenBudgetReserve).toBe(500); expect(config.compressionStrategy).toBe('sliding_window'); expect(config.dynamoDbTableName).toBe('sessions'); }); it('throws when no storage is configured', () => { vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-123'); expect(() => loadConfig()).toThrow(/No storage configured/); }); it('throws when AWS config is partial (region but no credentials)', () => { vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-123'); vi.stubEnv('AWS_REGION', 'us-east-1'); expect(() => loadConfig()).toThrow(/No storage configured/); }); it('picks Redis when both Redis and DynamoDB are configured', () => { vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-123'); vi.stubEnv('REDIS_URL', 'redis://localhost:6379'); vi.stubEnv('AWS_REGION', 'us-east-1'); vi.stubEnv('AWS_ACCESS_KEY_ID', 'akid'); vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'sak'); const config = loadConfig(); expect(config.redisUrl).toBe('redis://localhost:6379'); expect(config.awsRegion).toBe('us-east-1'); });});
Create the storage factory test at tests/services/storage-factory.test.ts. This mocks the Redis and DynamoDB packages so the tests run without real infrastructure.
Create the Anthropic client test at tests/services/anthropic-client.test.ts. This uses MSW to intercept calls to the real Anthropic API.
ts
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';import { http, HttpResponse } from 'msw';import { setupServer } from 'msw/node';const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';const mockResponse = { id: 'msg_123', type: 'message', role: 'assistant', content: [{ type: 'text', text: 'Hello! How can I help you today?' }], model: 'claude-sonnet-4-6', stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 10, output_tokens: 8 },};const handlers = [ http.post(ANTHROPIC_API_URL, () => { return HttpResponse.json(mockResponse); }),];const server = setupServer(...handlers);beforeAll(() => { server.listen({ onUnhandledRequest: 'bypass' });});afterAll(() => { server.close();});beforeEach(() => { vi.unstubAllEnvs(); server.resetHandlers();});describe('AnthropicClient', () => { it('sendMessage returns content and usage', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-123'); const { AnthropicClient } = await import('../../src/services/anthropic-client.js'); const client = new AnthropicClient('sk-ant-123'); const result = await client.sendMessage({ messages: [{ role: 'user', content: 'Hello' }], }); expect(result.content).toBe('Hello! How can I help you today?'); expect(result.usage.inputTokens).toBe(10); expect(result.usage.outputTokens).toBe(8); }); it('throws when API key is missing', async () => { const { AnthropicClient } = await import('../../src/services/anthropic-client.js'); expect(() => new AnthropicClient()).toThrow(/ANTHROPIC_API_KEY/); });});
The remaining test files — session-store.test.ts, session-store-init.test.ts, memory-service.test.ts, create-session-adapter.test.ts, chat.test.ts, sessions.test.ts, sessions-id.test.ts, memory.test.ts, and index.test.ts — are already present in the recipe’s tests/ directory. They follow the same patterns as the ones shown above: vi.mock for external dependencies, MSW for the Anthropic API, and direct handler invocation for API routes.
Now run the full test suite.
terminal
pnpm test
Expected output:pnpm test produces vitest-report.json with numFailedTests: 0, numTotalTests >= 60, and coverage at 90% or above on all four metrics (lines, branches, functions, statements).
Next steps
Add a summarization compression strategy. Change COMPRESSION_STRATEGY=summarization in your env and pass a SummarizerService to SummarizationStrategy for LLM-powered conversation summaries.
Wire up Redis in production. Set REDIS_URL to your Redis instance and call createStorageAdapter() to get a RedisAdapter with native TTL support.
Add authentication. Wrap the API routes with middleware that validates a customer identity token — every route already accepts a customerId parameter.