Skip to content
/ solutions / anthropic-knowledge-agent-for-paypal-smb-transaction-insights Anthropic Knowledge Agent for PayPal SMB Transaction Insights Natural‑language Q&A over PayPal transaction history so small business owners can ask about revenue, refunds, and customer payments without logging into the dashboard.
The problem SMB owners waste time manually searching their PayPal transaction feed to answer simple business questions like “what were my largest refunds last month?” or “which customers paid late last quarter?”.
Example artifact 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.
178 kB · 108 tests· 99.6% coverage· vitest passing
SHA-256 a0150062e4c6ec4609486036f35efe941d8578d985770d704922877d8d8ed34d Comments Sign in to commentSign in with GitHub to comment and vote.
© 2026 REAA Technologies Inc. — Open-Source AI Solutions for Small Business.
On this page Intro
In this tutorial you’ll build a knowledge agent that lets small business owners ask natural-language questions about their PayPal transactions — things like “What were my largest refunds last month?” or “Which customers paid late last quarter?” — and get answers grounded in real transaction data. The agent uses a hybrid RAG pipeline (vector + BM25 search) over Qdrant, Anthropic Claude for conversational responses, and agent memory with session continuity for multi-turn context.
Prerequisites
Node.js >= 22 — the runtime for the Next.js application
pnpm 10+ — the package manager (used via packageManager in package.json)
A running Qdrant instance — the vector database (start with docker run -p 6333:6333 qdrant/qdrant)
Anthropic API key — for Claude (ANTHROPIC_API_KEY)
OpenAI API key — for agent memory’s internal embedding and LLM extraction (OPENAI_API_KEY)
PayPal Developer account — for PayPal REST API credentials (client ID and secret; sandbox mode is fine)
Basic familiarity with Next.js App Router — route handlers, server and client components
Step 1: Scaffold the Next.js project
Start by creating a new Next.js project with the App Router and TypeScript:
npx create-next-app@latest paypal-knowledge-agent --typescript --app --import-alias="@/*"
cd paypal-knowledge-agent
Then install the dependencies — the REAA packages (hybrid RAG, agent memory, session continuity, cost telemetry), the Anthropic SDK, fastembed for local vector embeddings, the Qdrant client, and Zod for schema validation:
pnpm add @reaatech/hybrid-rag@0.1.0 @reaatech/hybrid-rag-pipeline@0.1.0 @reaatech/hybrid-rag-qdrant@0.1.0 @reaatech/agent-memory@0.1.0 @reaatech/session-continuity@0.1.0 @reaatech/llm-cost-telemetry@0.1.0 @anthropic-ai/sdk@0.101.0 fastembed@2.1.0 @qdrant/js-client-rest@1.18.0 zod@4.4.3
pnpm add -D vitest@4.1.8 msw@2.14.6 @vitest/coverage-v8@4.1.8 Expected output: Your package.json lists all dependencies with pinned versions. The pnpm-lock.yaml is generated alongside node_modules/.
Step 2: Create the type definitions Create the PayPal transaction types. These map to the shape PayPal’s REST API returns:
// src/types/paypal.ts
export interface PayPalOAuthToken {
access_token : string ;
token_type : string ;
expires_in : number ;
}
export interface PayPalTransactionAmount {
value : string ;
currency_code : string ;
}
export interface PayPalTransaction {
transaction_id : string ;
transaction_status : string ;
transaction_amount : PayPalTransactionAmount ;
transaction_initiation_date : string ;
transaction_updated_date : string ;
payer_info ?: Record < string , unknown >;
transaction_note ?: string ;
}
export interface PayPalTransactionSearchResponse {
transaction_details : PayPalTransaction [];
total_items : number ;
total_pages : number ;
} Next, create the chat API types:
// src/types/chat.ts
export interface ChatRequest {
sessionId ?: string ;
message : string ;
}
export interface ChatUsage {
inputTokens : number ;
outputTokens : number ;
costUsd : number ;
}
export interface ChatResponse {
sessionId : string ;
response : string ;
usage ?: ChatUsage ;
} Expected output: Two files under src/types/ with no TypeScript errors.
Step 3: Build the PayPal REST API client This client authenticates with OAuth2, fetches transactions from PayPal’s reporting API, handles token caching and expiry, auto-paginates through results, and retries on failures with exponential backoff via the retryWithBackoff utility from @reaatech/llm-cost-telemetry:
// src/lib/paypal.ts
import { z } from "zod" ;
import { retryWithBackoff } from "@reaatech/llm-cost-telemetry" ;
import type {
PayPalOAuthToken,
PayPalTransaction,
PayPalTransactionSearchResponse,
} from "../types/paypal.js" ;
const payPalOAuthTokenSchema = z. object ({
access_token: z. string (),
token_type: z. string (),
expires_in: z. number (),
});
const payPalTransactionAmountSchema = z. object ({
value: z. string (),
currency_code: z. string (),
Expected output: A file exporting createPayPalClient() which returns getOAuthToken, searchTransactions, and getTransactionsInRange. All PayPal API responses are validated with Zod schemas, and requests retry up to 3 times with exponential backoff.
Step 4: Set up embedding with fastembed The embedding service creates vector embeddings using the BGE-base-en model (768 dimensions). Documents are embedded in batches, and single query strings get a dedicated query-embedding method:
// src/lib/embedding.ts
import { EmbeddingModel, FlagEmbedding } from "fastembed" ;
let embedderInstance : FlagEmbedding | null = null ;
export async function createEmbedder () : Promise < FlagEmbedding > {
if ( ! embedderInstance) {
embedderInstance = await FlagEmbedding. init ({
model: EmbeddingModel.BGEBaseEN,
});
}
return embedderInstance;
}
export async function embedDocuments (
texts : string [],
batchSize = 2 ,
) : Promise < number [][]> {
const embedder = await createEmbedder ();
const results : number [][] = [];
for await ( const batch of embedder. embed (texts, batchSize)) {
results. push ( ... batch);
}
return results;
}
export async function embedQuery (text : string ) : Promise < number []> {
const embedder = await createEmbedder ();
return embedder. queryEmbed (text);
} Expected output: A lazy singleton embedder that downloads the BGE-base-en model on first use and reuses it for subsequent calls.
Step 5: Set up the Qdrant vector store The Qdrant wrapper uses @reaatech/hybrid-rag-qdrant to manage the paypal-transactions collection. It handles upserting embedding records and searching for similar vectors:
// src/lib/qdrant.ts
import {
QdrantClientWrapper,
type QdrantPoint,
} from "@reaatech/hybrid-rag-qdrant" ;
export function createQdrantClient () {
return new QdrantClientWrapper ({
url: process.env.QDRANT_URL ?? "http://localhost:6333" ,
apiKey: process.env.QDRANT_API_KEY,
collectionName: "paypal-transactions" ,
vectorSize: 768 ,
distance: "Cosine" ,
});
}
export async function initializeClient (
client : QdrantClientWrapper ,
) : Promise < void > {
await client. initialize ();
}
export interface EmbeddingRecord {
id : string ;
vector : Float32Array ;
documentId : string ;
content : string ;
source : string ;
metadata ?: Record < string , unknown >;
}
export async function upsertEmbeddings (
client : QdrantClientWrapper ,
records : EmbeddingRecord [],
) : Promise < void > {
const points : QdrantPoint [] = records. map ((r) => ({
id: r.id,
vector: Array. from (r.vector),
payload: {
documentId: r.documentId,
content: r.content,
source: r.source,
... (r.metadata ?? {}),
},
}));
await client. upsertBatch (points);
}
export async function searchSimilar (
client : QdrantClientWrapper ,
queryVector : number [],
topK ?: number ,
filter ?: Record < string , unknown >,
) {
return client. search ({
vector: queryVector,
topK: topK ?? 10 ,
filter,
});
}
export async function checkHealth (
client : QdrantClientWrapper ,
) : Promise < boolean > {
return client. healthCheck ();
} Expected output: A Qdrant client wrapper configured with 768-dimension vectors and Cosine distance. The collection is created on initialize() if it doesn’t exist.
Step 6: Build the ingestion pipeline The IngestionService wraps RAGPipeline from @reaatech/hybrid-rag-pipeline. It ingests PayPal transactions as documents, queries them with hybrid search (vector + BM25 with reciprocal rank fusion), and exposes collection stats:
// src/lib/ingestion.ts
import { RAGPipeline } from "@reaatech/hybrid-rag-pipeline" ;
import { ChunkingStrategy } from "@reaatech/hybrid-rag" ;
import type { RetrievalResult } from "@reaatech/hybrid-rag" ;
import type { PayPalTransaction } from "../types/paypal.js" ;
export class IngestionService {
private pipeline : RAGPipeline ;
constructor () {
this.pipeline = new RAGPipeline ({
qdrantUrl: process.env.QDRANT_URL ?? "http://localhost:6333" ,
qdrantApiKey: process.env.QDRANT_API_KEY,
collectionName: "paypal-transactions" ,
embeddingProvider: "local" ,
chunkingStrategy: ChunkingStrategy.FIXED_SIZE,
chunkSize: 512 ,
chunkOverlap: 50 ,
useHybrid: true ,
vectorWeight: 0.7 ,
bm25Weight: 0.3 ,
fusionStrategy: "rrf" ,
rerankerProvider: null ,
topK: 10 ,
});
}
async initialize () : Promise < void > {
await this.pipeline. initialize ();
}
async ingestTransactions (
transactions : PayPalTransaction [],
) : Promise < void > {
const documents : Array <{ id : string ; content : string }> = transactions. map ((txn) => ({
id: txn.transaction_id,
content: JSON. stringify ({
amount: txn.transaction_amount,
status: txn.transaction_status,
date: txn.transaction_initiation_date,
note: txn.transaction_note,
}),
}));
await this.pipeline. ingest (documents);
}
async queryTransactions (
query : string ,
options ?: { topK ?: number ; filter ?: Record < string , unknown > },
) : Promise < RetrievalResult []> {
return this.pipeline. query (query, {
topK: options?.topK ?? 5 ,
filter: { source: "paypal" , ... (options?.filter ?? {}) },
});
}
async getStats () {
return this.pipeline. getStats ();
}
async close () : Promise < void > {
await this.pipeline. close ();
}
} Expected output: An IngestionService class that, once initialized, can ingest PayPalTransaction[] objects and return search results via hybrid RAG (70% vector weight, 30% BM25).
Step 7: Set up agent memory The MemoryService wraps @reaatech/agent-memory, using OpenAI for both internal embedding (text-embedding-3-small) and LLM extraction (gpt-4o-mini) of facts and preferences from conversations:
// src/lib/memory.ts
import {
AgentMemory,
OpenAILLMProvider,
MemoryType,
} from "@reaatech/agent-memory" ;
import type { ConversationTurn, Memory } from "@reaatech/agent-memory" ;
export class MemoryService {
private memory : AgentMemory ;
constructor () {
const llmProvider = new OpenAILLMProvider ({
apiKey: process.env.OPENAI_API_KEY ?? "" ,
model: "gpt-4o-mini" ,
});
this.memory = new AgentMemory ({
storage: { provider: "memory" },
embedding: {
provider: "openai" ,
model: "text-embedding-3-small" ,
apiKey: process.env.OPENAI_API_KEY ?? "" ,
},
extraction: {
llmProvider,
enabledTypes: [
MemoryType.FACT,
MemoryType.PREFERENCE,
],
batchSize: 10 ,
confidenceThreshold: 0.7 ,
},
});
this.memory.events. on ( "memory:stored" , (event) => {
console. log ( `Memory stored: ${ event . type }` );
});
}
async storeFromChat (conversation : ConversationTurn []) : Promise < Memory []> {
return this.memory. extractAndStore (conversation);
}
async retrieve (
query : string ,
limit ?: number ,
) : Promise < Memory []> {
return this.memory. retrieve (query, { limit });
}
async runMaintenance () : Promise < void > {
await this.memory. runMaintenance ();
}
async close () : Promise < void > {
await this.memory. close ();
}
} Expected output: A MemoryService that extracts facts and preferences from chat conversations and retrieves relevant memories for new queries.
Step 8: Set up session continuity The session service manages conversational context across multiple turns. It uses an in-memory storage adapter that implements IStorageAdapter from @reaatech/session-continuity, with a simple heuristic tokenizer and a 4096-token budget using sliding-window compression:
// src/lib/session.ts
import {
SessionManager,
type IStorageAdapter,
type TokenCounter,
type Session,
type Message,
type MessageId,
type SessionId,
type HealthStatus,
type UpdateSessionOptions,
type SessionFilters,
type MessageQueryOptions,
SessionNotFoundError,
} from "@reaatech/session-continuity" ;
class InMemoryAdapter implements IStorageAdapter {
private sessions : Map < string , Session > = new Map ();
private messages :
Expected output: A SessionService that creates sessions, persists messages, enforces a 4096-token budget with 500 reserved for the system prompt, and uses sliding-window compression to stay within limits.
Step 9: Build cost telemetry The CostTelemetry singleton tracks Anthropic API token spend per call, computing cost from a pricing lookup table (per-million-token rates):
// src/lib/telemetry.ts
import {
generateId,
now,
calculateCostFromTokens,
type CostSpan,
} from "@reaatech/llm-cost-telemetry" ;
interface PricingEntry {
input : number ;
output : number ;
}
const ANTHROPIC_PRICING : Record < string , PricingEntry | undefined > = {
"claude-sonnet-4-6" : { input: 3 , output: 15 },
"claude-haiku-4-5-20251001" : { input: 1 , output: 5 },
};
let telemetryInstance : CostTelemetry | undefined ;
class CostTelemetry {
private calls : CostSpan [] = [];
private constructor () {}
static getInstance () : CostTelemetry {
if (telemetryInstance === undefined ) {
telemetryInstance = new CostTelemetry ();
}
return telemetryInstance;
}
recordCall (
model : string ,
inputTokens : number ,
outputTokens : number ,
) : CostSpan {
const pricing = this. getPricing (model);
const inputCost = calculateCostFromTokens (inputTokens, pricing.input);
const outputCost = calculateCostFromTokens (outputTokens, pricing.output);
const span : CostSpan = {
id: generateId (),
provider: "anthropic" ,
model,
inputTokens,
outputTokens,
costUsd: inputCost + outputCost,
tenant: "paypal-smb" ,
feature: "chat" ,
timestamp: now (),
};
this.calls. push (span);
return span;
}
getPricing (model : string ) : PricingEntry {
const pricing = ANTHROPIC_PRICING[model];
if ( ! pricing) {
return { input: 3 , output: 15 };
}
return pricing;
}
getCalls () : CostSpan [] {
return [ ... this.calls];
}
}
export { CostTelemetry, ANTHROPIC_PRICING };
export type { PricingEntry }; Expected output: A singleton CostTelemetry with recordCall() that returns a CostSpan with calculated USD cost. Unknown models fall back to the default pricing of $3/$15 per million input/output tokens.
Step 10: Build the chat orchestration service This is the core orchestrator. It ties together ingestion, memory, session, telemetry, and the Anthropic SDK into one streaming chat flow:
// src/lib/chat.ts
import Anthropic from '@anthropic-ai/sdk' ;
import { IngestionService } from './ingestion.js' ;
import { MemoryService } from './memory.js' ;
import { SessionService } from './session.js' ;
import { CostTelemetry } from './telemetry.js' ;
import type { RetrievalResult } from '@reaatech/hybrid-rag' ;
import type { Message } from '@reaatech/session-continuity' ;
import type { Memory } from '@reaatech/agent-memory' ;
interface RAGContext {
transactionResults : RetrievalResult [];
memories : Memory
Expected output: A createChatService() factory that returns a ChatService instance. Calling streamChatResponse() queries the RAG pipeline for relevant transactions, retrieves prior memories and conversation history, streams an Anthropic Claude response, records cost telemetry, persists the conversation, and stores extracted memories.
Step 11: Create the API route The /api/chat route handler accepts POST requests with { message, sessionId? } and returns the result as JSON. A GET endpoint provides a health check:
// app/api/chat/route.ts
import { NextRequest, NextResponse } from 'next/server' ;
import { z } from 'zod' ;
import { createChatService } from '../../../src/lib/chat.js' ;
interface ChatUsage {
inputTokens : number ;
outputTokens : number ;
costUsd : number ;
}
const chatRequestSchema = z. object ({
sessionId: z. string (). optional (),
message: z. string (). min ( 1 ),
});
export async function POST (req : NextRequest ) {
try {
const parsed = chatRequestSchema. safeParse ( await req. json ());
if ( ! parsed.success) {
return NextResponse. json ({ error: parsed.error.message }, { status: 400 });
}
const { sessionId: existingSessionId, message } = parsed.data;
const chatService = createChatService ();
await chatService. initialize ();
const result = await new Promise <{ sessionId : string ; response : string ; usage : ChatUsage }>(
(resolve, reject) => {
void chatService. streamChatResponse (
existingSessionId ?? crypto. randomUUID (),
message,
() => {},
(result : { sessionId : string ; response : string ; usage : ChatUsage } | { sessionId : string ; error : string }) => {
if ( 'error' in result) {
reject ( new Error (result.error));
} else {
resolve (result);
}
},
);
},
);
return NextResponse. json (result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error' ;
return NextResponse. json ({ error: message }, { status: 500 });
}
}
export function GET (_req : NextRequest ) {
void _req;
return NextResponse. json ({ status: 'ok' , service: 'paypal-knowledge-agent' });
} Expected output: POST /api/chat returns { sessionId, response, usage } with a 200 status. Missing or empty message returns 400. GET /api/chat returns { status: 'ok', service: 'paypal-knowledge-agent' }.
Step 12: Create the chat UI The root page is a 'use client' component with a chat interface — text input, send button, scrollable message list, and token usage display below each assistant response:
// app/page.tsx
'use client' ;
import { useState, useRef, useCallback } from 'react' ;
interface Usage {
inputTokens : number ;
outputTokens : number ;
costUsd : number ;
}
interface ChatMessage {
role : 'user' | 'assistant' ;
content : string ;
usage ?: Usage ;
}
export default function Home () {
const [messages, setMessages] = useState <
Expected output: A chat interface at http://localhost:3000 with a text input, Send button, and a conversation panel showing messages with token usage and cost below each assistant response.
Step 13: Add server instrumentation The src/instrumentation.ts file runs once at server startup. It schedules agent memory maintenance every 30 minutes:
// src/instrumentation.ts
export async function register () {
if (process.env.NEXT_RUNTIME === "nodejs" ) {
const { MemoryService } = await import ( "./lib/memory.js" );
const memory = new MemoryService ();
setInterval (() => {
void memory. runMaintenance ();
}, 30 * 60 * 1000 );
}
} To make this work, enable the instrumentation hook in your Next.js config:
// next.config.ts
import type { NextConfig } from "next" ;
const nextConfig : NextConfig = {
experimental: {
instrumentationHook: true ,
},
};
export default nextConfig; The key is spelled instrumentationHook — not clientInstrumentationHook or instrumentation. Without this flag, the register() function never fires and the instrumentation file is dead code.
Expected output: On server start, register() fires, creates a MemoryService instance, and starts a 30-minute interval for maintenance.
Step 14: Configure environment variables Create your .env.local file (never commit real values):
# .env.local
ANTHROPIC_API_KEY=<your-anthropic-key>
OPENAI_API_KEY=<your-openai-key>
PAYPAL_CLIENT_ID=<your-paypal-client-id>
PAYPAL_CLIENT_SECRET=<your-paypal-client-secret>
PAYPAL_API_BASE_URL=https://api-m.sandbox.paypal.com
QDRANT_URL=http://localhost:6333
QDRANT_API_KEY=<your-qdrant-key> The .env.example file in the project already lists these for reference. The PayPal API base URL defaults to sandbox (api-m.sandbox.paypal.com); switch to https://api-m.paypal.com for production.
Expected output: All seven env vars configured. The app reads them via process.env.X in server code.
Step 15: Run the tests The test suite covers every module with unit and integration tests. Run it with:
This runs Vitest with coverage reporting. All 108 tests across 12 test files should pass:
✓ tests/index.test.ts (1 test)
✓ tests/integration/chat-flow.test.ts (3 tests)
✓ tests/lib/chat.test.ts (14 tests)
✓ tests/lib/embedding.test.ts (3 tests)
✓ tests/lib/ingestion.test.ts (9 tests)
✓ tests/lib/instrumentation.test.ts (3 tests)
✓ tests/lib/memory.test.ts (8 tests)
✓ tests/lib/paypal.test.ts (10 tests)
✓ tests/lib/qdrant.test.ts (6 tests)
✓ tests/lib/session.test.ts (36 tests)
✓ tests/lib/telemetry.test.ts (5 tests)
✓ tests/app/api/chat/route.test.ts (10 tests)
Tests 108 passed (108) Coverage exceeds 99% on lines, 98% on functions, and 92% on branches.
You can also run TypeScript and linting checks separately:
Expected output: Zero TypeScript errors, zero ESLint errors, and all 108 tests passing.
Next steps
Add PayPal webhook integration — listen for PAYMENT.CAPTURE.COMPLETED and similar webhooks to auto-ingest transactions as they occur
Replace in-memory session storage with a database adapter (Postgres, Redis) so sessions survive server restarts
Add a transaction ingestion management page — a UI to trigger manual re-ingestion, view ingestion status, and select date ranges
Deploy with persistent Qdrant — set up a production Qdrant cluster and configure QDRANT_URL and QDRANT_API_KEY accordingly
Add user authentication — integrate NextAuth.js so each SMB user has their own isolated session and transaction scope
});
const payPalTransactionSchema = z. object ({
transaction_id: z. string (),
transaction_status: z. string (),
transaction_amount: payPalTransactionAmountSchema,
transaction_initiation_date: z. string (),
transaction_updated_date: z. string (),
payer_info: z. record (z. string (), z. unknown ()). optional (),
transaction_note: z. string (). optional (),
});
const payPalTransactionSearchResponseSchema = z. object ({
transaction_details: z. array (payPalTransactionSchema),
total_items: z. number (),
total_pages: z. number (),
});
export interface PayPalClientConfig {
clientId : string ;
clientSecret : string ;
baseUrl : string ;
}
export interface SearchTransactionsParams {
startDate : string ;
endDate : string ;
pageSize ?: number ;
page ?: number ;
}
interface InternalState {
accessToken : string | null ;
tokenExpiresAt : number ;
}
function createInternalState () : InternalState {
return { accessToken: null , tokenExpiresAt: 0 };
}
async function fetchNewToken (
clientId : string ,
clientSecret : string ,
baseUrl : string ,
) : Promise < PayPalOAuthToken > {
const credentials = Buffer. from ( `${ clientId }:${ clientSecret }` ). toString ( "base64" );
const response = await retryWithBackoff ( async () => {
const res = await fetch ( `${ baseUrl }/v1/oauth2/token` , {
method: "POST" ,
headers: {
Authorization: `Basic ${ credentials }` ,
"Content-Type" : "application/x-www-form-urlencoded" ,
},
body: new URLSearchParams ({ grant_type: "client_credentials" }),
});
if ( ! res.ok) {
throw new Error ( `PayPal OAuth failed: ${ String ( res . status ) } ${ res . statusText }` );
}
const data : unknown = await res. json ();
return data;
}, {
maxRetries: 3 ,
initialDelayMs: 1000 ,
maxDelayMs: 10000 ,
backoffMultiplier: 2 ,
});
return payPalOAuthTokenSchema. parse (response);
}
export function createPayPalClient (config : PayPalClientConfig ) {
const state = createInternalState ();
async function getOAuthToken () : Promise < string > {
if (state.accessToken && Date. now () < state.tokenExpiresAt) {
return state.accessToken;
}
const token = await fetchNewToken (
config.clientId,
config.clientSecret,
config.baseUrl,
);
state.accessToken = token.access_token;
state.tokenExpiresAt = Date. now () + token.expires_in * 1000 ;
return state.accessToken;
}
async function searchTransactions (
accessToken : string ,
params : SearchTransactionsParams ,
attempt = 0 ,
) : Promise < PayPalTransactionSearchResponse > {
const searchParams = new URLSearchParams ({
start_date: params.startDate,
end_date: params.endDate,
fields: "all" ,
page_size: String (params.pageSize ?? 100 ),
page: String (params.page ?? 1 ),
});
const response = await retryWithBackoff ( async () => {
const res = await fetch (
`${ config . baseUrl }/v2/reporting/transactions?${ searchParams }` ,
{
headers: {
Authorization: `Bearer ${ accessToken }` ,
"Content-Type" : "application/json" ,
},
},
);
if (res.status === 401 && attempt === 0 ) {
state.accessToken = null ;
state.tokenExpiresAt = 0 ;
const newToken = await getOAuthToken ();
return searchTransactions (newToken, params, 1 );
}
if ( ! res.ok) {
throw new Error ( `PayPal API error: ${ String ( res . status ) } ${ res . statusText }` );
}
const data : unknown = await res. json ();
return data;
}, {
maxRetries: 3 ,
initialDelayMs: 1000 ,
maxDelayMs: 10000 ,
backoffMultiplier: 2 ,
});
return payPalTransactionSearchResponseSchema. parse (response);
}
async function getTransactionsInRange (
startDate : string ,
endDate : string ,
options ?: { maxPages ?: number ; pageSize ?: number },
) : Promise < PayPalTransaction []> {
const maxPages = options?.maxPages ?? 10 ;
const pageSize = options?.pageSize ?? 100 ;
const allTransactions : PayPalTransaction [] = [];
const token = await getOAuthToken ();
for ( let page = 1 ; page <= maxPages; page ++ ) {
const response = await searchTransactions (token, {
startDate,
endDate,
pageSize,
page,
});
allTransactions. push ( ... response.transaction_details);
if (page >= response.total_pages) {
break ;
}
}
return allTransactions;
}
return {
getOAuthToken,
searchTransactions,
getTransactionsInRange,
};
}
Map
<
string
,
Message
[]>
=
new
Map
();
private messageSequence = 0 ;
createSession (
session : Omit < Session , "id" | "createdAt" | "lastActivityAt" >,
) : Promise < Session > {
const now = new Date ();
const newSession : Session = {
... session,
id: crypto. randomUUID (),
createdAt: now,
lastActivityAt: now,
version: 1 ,
};
this.sessions. set (newSession.id, newSession);
return Promise . resolve (newSession);
}
getSession (id : SessionId ) : Promise < Session | null > {
return Promise . resolve (this.sessions. get (id) ?? null );
}
updateSession (
id : SessionId ,
updates : Partial < Session >,
options ?: UpdateSessionOptions ,
) : Promise < Session > {
const existing = this.sessions. get (id);
if ( ! existing) {
throw new SessionNotFoundError (id);
}
if (
options?.expectedVersion !== undefined &&
existing.version !== options.expectedVersion
) {
throw new SessionNotFoundError (id);
}
const updated : Session = {
... existing,
... updates,
version: (existing.version ?? 0 ) + 1 ,
};
this.sessions. set (id, updated);
return Promise . resolve (updated);
}
deleteSession (id : SessionId ) : Promise < void > {
this.sessions. delete (id);
this.messages. delete (id);
return Promise . resolve ();
}
listSessions (filters ?: SessionFilters ) : Promise < Session []> {
let result = Array. from (this.sessions. values ());
if (filters?.userId) result = result. filter ((s) => s.userId === filters.userId);
if (filters?.status) result = result. filter ((s) => s.status === filters.status);
if (filters?.tags) result = result. filter ((s) => filters.tags ! . some ((tag) => s.metadata.tags?. includes (tag)));
if (filters?.createdAfter) result = result. filter ((s) => s.createdAt >= filters.createdAfter ! );
if (filters?.createdBefore) result = result. filter ((s) => s.createdAt <= filters.createdBefore ! );
if (filters?.activeAgentId) result = result. filter ((s) => s.activeAgentId === filters.activeAgentId);
const offset = filters?.offset ?? 0 ;
const limit = filters?.limit ?? result.length;
return Promise . resolve (result. slice (offset, offset + limit));
}
addMessage (
sessionId : SessionId ,
message : Omit < Message , "id" | "sessionId" | "createdAt" >,
) : Promise < Message > {
const sessionMessages = this.messages. get (sessionId) ?? [];
const newMessage : Message = {
... message,
id: crypto. randomUUID (),
sessionId,
createdAt: new Date (),
sequence: this.messageSequence ++ ,
};
sessionMessages. push (newMessage);
this.messages. set (sessionId, sessionMessages);
return Promise . resolve (newMessage);
}
getMessages (
sessionId : SessionId ,
options ?: MessageQueryOptions ,
) : Promise < Message []> {
const sessionMessages = this.messages. get (sessionId) ?? [];
let result = [ ... sessionMessages];
if (options?.roles) result = result. filter ((m) => options.roles ! . includes (m.role));
if (options?.after) result = result. filter ((m) => m.createdAt >= options.after ! );
if (options?.before) result = result. filter ((m) => m.createdAt <= options.before ! );
if (options?.order === "desc" ) result. reverse ();
const offset = options?.offset ?? 0 ;
const limit = options?.limit ?? result.length;
return Promise . resolve (result. slice (offset, offset + limit));
}
updateMessage (
sessionId : SessionId , messageId : MessageId , updates : Partial < Message >,
) : Promise < Message > {
const sessionMessages = this.messages. get (sessionId);
if ( ! sessionMessages) throw new SessionNotFoundError (sessionId);
const index = sessionMessages. findIndex ((m) => m.id === messageId);
if (index === - 1 ) throw new Error ( `Message ${ messageId } not found in session ${ sessionId }` );
const updated : Message = { ... sessionMessages[index], ... updates };
sessionMessages[index] = updated;
return Promise . resolve (updated);
}
deleteMessage (sessionId : SessionId , messageId : MessageId ) : Promise < void > {
const sessionMessages = this.messages. get (sessionId);
if ( ! sessionMessages) return Promise . resolve ();
this.messages. set (sessionId, sessionMessages. filter ((m) => m.id !== messageId));
return Promise . resolve ();
}
deleteAllMessages (sessionId : SessionId ) : Promise < void > {
this.messages. set (sessionId, []);
return Promise . resolve ();
}
getExpiredSessions (before : Date ) : Promise < SessionId []> {
const expired : SessionId [] = [];
for ( const [id, session] of this.sessions) {
if (session.expiresAt && session.expiresAt < before) expired. push (id);
}
return Promise . resolve (expired);
}
health () : Promise < HealthStatus > {
return Promise . resolve ({
status: "healthy" ,
details: { adapter: "InMemoryAdapter" , sessionCount: this.sessions.size },
});
}
close () : Promise < void > {
this.sessions. clear ();
this.messages. clear ();
return Promise . resolve ();
}
}
class SimpleTokenizer implements TokenCounter {
readonly model = "claude-opus-4-6" ;
readonly tokenizer = "simple-heuristic" ;
count (text : string ) : number {
return Math. ceil (text.length / 4 );
}
countMessages (messages : Message []) : number {
let total = 0 ;
for ( const msg of messages) {
total += this. count (msg.content as string );
}
return total;
}
}
export { InMemoryAdapter, SimpleTokenizer };
export class SessionService {
private manager : SessionManager ;
constructor () {
this.manager = new SessionManager ({
storage: new InMemoryAdapter (),
tokenCounter: new SimpleTokenizer (),
tokenBudget: {
maxTokens: 4096 ,
reserveTokens: 500 ,
overflowStrategy: "compress" ,
},
compression: {
strategy: "sliding_window" ,
targetTokens: 3500 ,
},
});
}
async getOrCreate (sessionId ?: string ) : Promise < Session > {
if (sessionId) {
try {
return await this.manager. getSession (sessionId);
} catch (error) {
if (error instanceof SessionNotFoundError ) {
return this.manager. createSession ({ userId: "paypal-smb-user" });
}
throw error;
}
}
return this.manager. createSession ({ userId: "paypal-smb-user" });
}
async addMessage (
sessionId : SessionId ,
role : "user" | "assistant" | "system" ,
content : string ,
) : Promise < Message > {
return this.manager. addMessage (sessionId, { role, content });
}
async getContext (sessionId : SessionId ) : Promise < Message []> {
return this.manager. getConversationContext (sessionId);
}
async close () : Promise < void > {
await this.manager. close ();
}
}
[];
}
interface ChatServiceDeps {
ingestion ?: IngestionService ;
memory ?: MemoryService ;
session ?: SessionService ;
telemetry ?: CostTelemetry ;
anthropic ?: Anthropic ;
}
class ChatService {
private ingestion : IngestionService ;
private memory : MemoryService ;
private session : SessionService ;
private telemetry : CostTelemetry ;
private anthropic : Anthropic ;
constructor (deps ?: ChatServiceDeps ) {
this.ingestion = deps?.ingestion ?? new IngestionService ();
this.memory = deps?.memory ?? new MemoryService ();
this.session = deps?.session ?? new SessionService ();
this.telemetry = deps?.telemetry ?? CostTelemetry. getInstance ();
this.anthropic = deps?.anthropic ?? new Anthropic ({ apiKey: process.env.ANTHROPIC_API_KEY });
}
async initialize () : Promise < void > {
await this.ingestion. initialize ();
}
async buildRAGContext (query : string ) : Promise < RAGContext > {
const [transactionResults, memories] = await Promise . all ([
this.ingestion. queryTransactions (query),
this.memory. retrieve (query),
]);
return { transactionResults, memories };
}
buildSystemPrompt (ragContext : RAGContext ) : string {
const parts : string [] = [
'You are a PayPal transaction insights assistant. Answer questions using the retrieved transaction data below. Cite transaction IDs when relevant.' ,
];
if (ragContext.transactionResults.length > 0 ) {
parts. push ( '' , 'Retrieved transaction data:' );
for ( const result of ragContext.transactionResults) {
parts. push ( `- ${ result . content }` );
}
}
if (ragContext.memories.length > 0 ) {
parts. push ( '' , 'Relevant memories from past conversations:' );
for ( const mem of ragContext.memories) {
parts. push ( `- ${ mem . content }` );
}
}
return parts. join ( '\n' );
}
formatContextForMessages (
_systemPrompt : string ,
_ragContext : RAGContext ,
conversationHistory : Message [],
) : Anthropic . MessageParam [] {
const messages : Anthropic . MessageParam [] = [];
for ( const msg of conversationHistory) {
const content = typeof msg.content === 'string' ? msg.content : JSON. stringify (msg.content);
if (msg.role === 'user' || msg.role === 'assistant' ) {
messages. push ({ role: msg.role, content });
}
}
return messages;
}
async streamChatResponse (
sessionId : string ,
userMessage : string ,
onChunk : (text : string ) => void ,
onComplete : (
result :
| { sessionId : string ; response : string ; usage : { inputTokens : number ; outputTokens : number ; costUsd : number } }
| { sessionId : string ; error : string },
) => void ,
) : Promise < void > {
try {
const session = await this.session. getOrCreate (sessionId);
const actualSessionId = session.id;
const ragContext = await this. buildRAGContext (userMessage);
const conversationHistory = await this.session. getContext (actualSessionId);
const systemPrompt = this. buildSystemPrompt (ragContext);
const formattedMessages = this. formatContextForMessages (systemPrompt, ragContext, conversationHistory);
formattedMessages. push ({ role: 'user' , content: userMessage });
const stream = this.anthropic.messages. stream ({
model: 'claude-sonnet-4-6' ,
max_tokens: 2048 ,
system: systemPrompt,
messages: formattedMessages,
});
for await ( const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta' ) {
onChunk (event.delta.text);
}
}
const finalMessage = await stream. finalMessage ();
const inputTokens = finalMessage.usage.input_tokens;
const outputTokens = finalMessage.usage.output_tokens;
let response = '' ;
for ( const block of finalMessage.content) {
if (block.type === 'text' ) {
response += block.text;
}
}
const span = this.telemetry. recordCall ( 'claude-sonnet-4-6' , inputTokens, outputTokens);
await this.session. addMessage (actualSessionId, 'user' , userMessage);
await this.session. addMessage (actualSessionId, 'assistant' , response);
await this.memory. storeFromChat ([
{ speaker: 'user' , content: userMessage, timestamp: new Date () },
{ speaker: 'agent' , content: response, timestamp: new Date () },
]);
onComplete ({
sessionId: actualSessionId,
response,
usage: { inputTokens, outputTokens, costUsd: span.costUsd },
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' ;
onComplete ({ sessionId, error: errorMessage });
}
}
}
export function createChatService (deps ?: ChatServiceDeps ) : ChatService {
return new ChatService (deps);
}
ChatMessage
[]>([]);
const [input, setInput] = useState ( '' );
const [sessionId, setSessionId] = useState < string | null >( null );
const [loading, setLoading] = useState ( false );
const messagesEndRef = useRef < HTMLDivElement >( null );
const handleSend = useCallback ( async () => {
if ( ! input. trim () || loading) return ;
const userMessage = input. trim ();
setInput ( '' );
setLoading ( true );
setMessages ((prev) => [ ... prev, { role: 'user' , content: userMessage }]);
try {
const response = await fetch ( '/api/chat' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON. stringify ({
message: userMessage,
sessionId: sessionId ?? undefined ,
}),
});
if ( ! response.ok) {
const errorData : { error : string } = await response. json () as { error : string };
throw new Error (errorData.error || 'Request failed' );
}
const data = await response. json () as {
sessionId : string ;
response : string ;
usage : Usage ;
};
if ( ! sessionId && data.sessionId) {
setSessionId (data.sessionId);
}
setMessages ((prev) => [
... prev,
{ role: 'assistant' , content: data.response, usage: data.usage },
]);
} catch (error) {
setMessages ((prev) => [
... prev,
{
role: 'assistant' ,
content: `Error: ${ error instanceof Error ? error . message : 'Something went wrong'}` ,
},
]);
} finally {
setLoading ( false );
}
}, [input, loading, sessionId]);
return (
<div
style ={{
display: 'flex' ,
flexDirection: 'column' ,
height: '100vh' ,
maxWidth: '800px' ,
margin: '0 auto' ,
padding: '16px' ,
}}
>
<h1 style ={{ marginBottom: '16px' , fontSize: '24px' , fontWeight: 700 }}>
PayPal Transaction Insights
</h1>
<div
style ={{
flex: 1 ,
overflowY: 'auto' ,
marginBottom: '16px' ,
display: 'flex' ,
flexDirection: 'column' ,
gap: '12px' ,
padding: '8px 0' ,
}}
>
{ messages .length === 0 && (
<div style ={{ color: '#666' , textAlign: 'center' , padding: '32px' }}>
Ask a question about your PayPal transactions.
</div>
)}
{ messages . map (( msg , i ) => (
<div
key ={ i }
style ={{
alignSelf: msg . role === 'user' ? 'flex-end' : 'flex-start' ,
maxWidth: '80%' ,
}}
>
<div
style ={{
padding: '10px 14px' ,
borderRadius: '12px' ,
backgroundColor: msg . role === 'user' ? '#0070f3' : '#e5e5ea' ,
color: msg . role === 'user' ? 'white' : 'black' ,
whiteSpace: 'pre-wrap' ,
}}
>
{ msg . content }
</div>
{ msg . usage && (
<div
style ={{
fontSize: '11px' ,
color: '#666' ,
marginTop: '4px' ,
padding: '0 4px' ,
}}
>
Tokens: { msg . usage . inputTokens } in / { msg . usage . outputTokens } out
{ ' ' }·{ ' ' }${ msg . usage . costUsd . toFixed ( 6 )}
</div>
)}
</div>
))}
{ loading && (
<div style ={{ alignSelf: 'flex-start' , color: '#666' , padding: '8px' }}>
Thinking...
</div>
)}
<div ref ={ messagesEndRef } />
</div>
<div style ={{ display: 'flex' , gap: '8px' }}>
<input
value ={ input }
onChange ={( e ) => { setInput ( e . target . value ); }}
onKeyDown ={( e ) => {
if ( e . key === 'Enter' ) { void handleSend (); }
}}
placeholder = "Ask about your transactions..."
disabled ={ loading }
style ={{
flex: 1 ,
padding: '10px 14px' ,
borderRadius: '8px' ,
border: '1px solid #ccc' ,
fontSize: '16px' ,
}}
/>
<button
onClick ={() => { void handleSend (); }}
disabled ={ loading || ! input . trim ()}
style ={{
padding: '10px 20px' ,
borderRadius: '8px' ,
border: 'none' ,
backgroundColor: '#0070f3' ,
color: 'white' ,
fontSize: '16px' ,
cursor: loading || ! input . trim () ? 'not-allowed' : 'pointer' ,
opacity: loading || ! input . trim () ? 0.5 : 1 ,
}}
>
Send
</button>
</div>
</div>
);
}