AWS Bedrock Knowledge Agent for Buildium Tenant Self-Service
Answer tenant questions about leases, payments, and maintenance schedules by pulling real‑time data from Buildium and a knowledge base of property documents.
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 retrieval-augmented knowledge agent that answers tenant questions about leases, payments, and maintenance schedules. The agent pulls real-time data from the Buildium REST API, stores conversation history in DynamoDB, caches semantically similar questions in Qdrant, and generates answers through AWS Bedrock — all wired together with the @reaatech/* package family for memory, caching, session continuity, cost telemetry, and structured output repair. You’ll build a single POST /api/chat endpoint in Next.js (App Router) that a property management portal can call directly.
Prerequisites
Node.js 22+ and pnpm 10 installed
An AWS account with access to Bedrock and DynamoDB. Configure credentials via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
A Buildium API key — sign up at buildium.com and generate credentials from the Integrations panel
A Qdrant instance — local (docker run -p 6333:6333 qdrant/qdrant) or a cloud cluster
An OpenAI API key for text embeddings (text-embedding-3-small)
A Langfuse account (optional, for tracing) — get public + secret keys from your project settings
Familiarity with TypeScript and Express-style request handlers
Step 1: Scaffold the project and install dependencies
Create a new Next.js project with the App Router, then install all runtime and dev dependencies. The versions below are pinned exactly so the build is reproducible.
Important: The app/ directory stays at the project root (no --src-dir flag) because the route handler imports library code from src/ via relative paths.
Open package.json and set the dependencies to these exact versions:
Expected output: A pnpm-lock.yaml is generated and node_modules is populated with all packages.
Step 2: Configure environment variables
Create .env.example with every environment variable the application reads at runtime. The config service validates these on startup and throws if any are missing.
env
# Env vars used by aws-bedrock-knowledge-agent-for-buildium-tenant-self-service.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# AWS BedrockAWS_REGION=us-east-1AWS_ACCESS_KEY_ID=<your-access-key>AWS_SECRET_ACCESS_KEY=<your-secret># Buildium REST APIBUILDIUM_API_KEY=<your-buildium-api-key>BUILDIUM_API_BASE_URL=https://api.buildium.com# Qdrant vector DBQDRANT_URL=http://localhost:6333QDRANT_API_KEY=<your-qdrant-key># OpenAI (for embeddings)OPENAI_API_KEY=<your-openai-api-key># Langfuse observabilityLANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_HOST=https://cloud.langfuse.com# DynamoDB session tableSESSION_TABLE_NAME=sessions# Bedrock modelBEDROCK_MODEL_ID=anthropic.claude-sonnet-4-v1:0# Express health serverHEALTH_PORT=3001
Copy this to .env and fill in your real values:
terminal
cp .env.example .env
Expected output:.env exists in the project root with your credentials.
Step 3: Define TypeScript types
Create src/types.ts with the data types the agent uses to model tenants, leases, payments, maintenance requests, and the API contract between the frontend and the backend.
Expected output:src/types.ts is type-checked and re-exported from src/index.ts.
Step 4: Create the config service
The config service wraps @reaatech/llm-cost-telemetry’s getEnvVar to read every environment variable and produce a validated AppConfig object. It caches the result so the singleton pattern stays efficient across route handler invocations.
Expected output: You can import getConfig() from any server-side module and receive a fully populated AppConfig object. Missing variables throw on first access — no silent undefined.
Step 5: Build the Buildium REST client
This client wraps four Buildium API endpoints: tenants, leases, payments, and maintenance requests. The getQueryContext method fetches all four in parallel for a given tenant and assembles a BuildiumQueryContext — the data structure the agent passes to the LLM as context.
Expected output:new BuildiumClient(baseUrl, apiKey) creates an authenticated client. getQueryContext("tenant-123") returns a complete BuildiumQueryContext with all related data.
Step 6: Build the Bedrock LLM client
The BedrockClient wraps two AWS SDK operations under a single class: ConverseCommand for direct LLM calls and RetrieveAndGenerateCommand for Bedrock Knowledge Base queries. The route handler uses the generate method, which sends a prompt with system instructions and returns the model’s text, token counts, and stop reason.
Expected output:new BedrockClient("us-east-1") creates both SDK clients. Calling generate({ prompt, system, maxTokens }) returns a parsed response with text and token usage fields.
Step 7: Create the memory service
The MemoryService wraps @reaatech/agent-memory with a CachedEmbeddingProvider (OpenAI text-embedding-3-small behind an in-memory LRU cache), an InMemoryMemoryStorage for the embedding store, and an OpenAILLMProvider (gpt-4o-mini) for extracting facts and preferences from conversation turns. Every call to extractAndStore or retrieve delegates to the underlying AgentMemory instance.
ts
// src/services/memory-service.tsimport { AgentMemory, OpenAILLMProvider, MemoryType, type ConversationTurn } from "@reaatech/agent-memory";import { OpenAIEmbeddingProvider, CachedEmbeddingProvider, InMemoryEmbeddingCache,} from "@reaatech/agent-memory-embedding";import { InMemoryMemoryStorage } from "@reaatech/agent-memory-storage";export class MemoryService { private agentMemory: AgentMemory; constructor(openaiApiKey: string) { const embedder = new CachedEmbeddingProvider( new OpenAIEmbeddingProvider({ apiKey: openaiApiKey, model: "text-embedding-3-small", dimensions: 1536, }), new InMemoryEmbeddingCache({ maxSize: 1000, ttlMs: 60000 }) ); this.agentMemory = new AgentMemory({ storage: new InMemoryMemoryStorage(), embedding: embedder, extraction: { llmProvider: new OpenAILLMProvider({ apiKey: openaiApiKey, model: "gpt-4o-mini", }), enabledTypes: [MemoryType.FACT, MemoryType.PREFERENCE], batchSize: 10, confidenceThreshold: 0.7, }, }); } async extractAndStore(conversation: unknown): Promise<unknown> { return this.agentMemory.extractAndStore(conversation as ConversationTurn[]); } async retrieve( query: string, options?: Record<string, unknown> ): Promise<unknown> { return this.agentMemory.retrieve(query, options); } async runMaintenance(): Promise<void> { return this.agentMemory.runMaintenance(); } async close(): Promise<void> { return this.agentMemory.close(); }}
Expected output:new MemoryService(apiKey) sets up the full extraction pipeline. Calling retrieve("lease balance") returns semantically similar memories from previous conversations.
Step 8: Create the LLM cache service
The CacheService combines an in-memory cache (InMemoryAdapter) with Qdrant vector storage (QdrantAdapter) through @reaatech/llm-cache’s CacheEngine. Every cached entry is scoped to the tenant-qa use case and tagged with the Bedrock model ID, so the agent avoids redundant LLM calls when a tenant asks a semantically identical question.
Expected output:get("what is my rent?") returns { hit: true, entry: { response: ... } } when a semantically similar question was cached, or { hit: false } on a miss.
Step 9: Create the session service
The SessionService uses @reaatech/session-continuity with a DynamoDB storage adapter. It manages the full lifecycle: creating sessions, appending messages, retrieving conversation history, and cleaning up expired sessions. The token budget is 4,096 tokens with a sliding-window compression strategy that keeps the most recent 3,500 tokens.
Expected output:createSession("tenant-123") returns a Session with a UUID. addMessage("sess-uuid", { role: "user", content: "Hi" }) stores the message in DynamoDB.
Step 10: Create the cost telemetry and response repair services
The CostTelemetryService records LLM and cache cost spans to Langfuse using @reaatech/llm-cost-telemetry. The ResponseRepair uses @reaatech/structured-repair-core with a Zod schema to validate and repair the LLM’s raw JSON output before returning it to the caller.
Expected output:recordSpan({ provider: "bedrock", model: "...", ... }) returns a CostSpan with a computed costUsd. repairAgentOutput('{"reply":"Hi"}') returns a parsed and validated object; garbage input returns a safe fallback.
Step 11: Build the KnowledgeAgent orchestrator
This is the core class that ties everything together. Given a TenantMessage, the KnowledgeAgent:
Resolves or creates a session
Fetches live Buildium data via getQueryContext
Checks the LLM cache for a match
If cached: returns immediately
If not: retrieves relevant memories, gets conversation history, builds a prompt with Buildium context + memories + history, calls Bedrock, repairs the output, caches the result, stores memories, records telemetry, persists conversation, and returns the AgentResponse.
ts
// src/services/knowledge-agent.tsimport type { TenantMessage, AgentResponse, BuildiumQueryContext, AppConfig } from "../types.js";import type { BuildiumClient } from "../lib/buildium-client.js";import type { BedrockClient } from "../lib/llm-client.js";import type { MemoryService } from "./memory-service.js";import type { CacheService } from "./cache-service.js";import type { SessionService } from "./session-service.js";import type { CostTelemetryService } from "./cost-telemetry-service.js";import type { ResponseRepair } from "./response-repair.js";const SYSTEM_PROMPT =
Expected output:agent.handleMessage({ message: "What is my balance?", tenantId: "t1" }) returns a complete AgentResponse with reply, session ID, sources, and cost breakdown.
Step 12: Wire up the Next.js API route
The route handler at app/api/chat/route.ts accepts POST requests, validates the body with Zod, lazily initializes the KnowledgeAgent singleton, and delegates to handleMessage. Errors produce structured JSON responses — 400 for validation failures, 500 for internal errors.
ts
// app/api/chat/route.tsimport { type NextRequest, NextResponse } from "next/server";import { z } from "zod";import { getConfig } from "../../../src/lib/config.js";import { BuildiumClient } from "../../../src/lib/buildium-client.js";import { BedrockClient } from "../../../src/lib/llm-client.js";import { MemoryService } from "../../../src/services/memory-service.js";import { CacheService } from "../../../src/services/cache-service.js";import { SessionService } from "../../../src/services/session-service.js";import { CostTelemetryService } from "../../../src/services/cost-telemetry-service.js";import { ResponseRepair } from "../../../src/services/response-repair.js";import { KnowledgeAgent } from "../../../src/services/knowledge-agent.js";import type { AgentResponse } from "../../../src/types.js";const TenantMessageSchema = z.object({ sessionId: z.string().optional(), message: z.string().min(1, "Message is required"), tenantId: z.string().min(1, "Tenant ID is required"), propertyId: z.string().optional(),});let agent: KnowledgeAgent | null = null;function getAgent(): KnowledgeAgent { if (!agent) { const config = getConfig(); agent = new KnowledgeAgent({ buildium: new BuildiumClient(config.buildiumBaseUrl, config.buildiumApiKey), bedrock: new BedrockClient(config.awsRegion), memory: new MemoryService(config.openaiApiKey), cache: new CacheService({ qdrantUrl: config.qdrantUrl, qdrantApiKey: config.qdrantApiKey, openaiApiKey: config.openaiApiKey, bedrockModelId: config.bedrockModelId, }), sessions: new SessionService({ region: config.awsRegion, tableName: config.sessionTableName }), telemetry: new CostTelemetryService({ publicKey: config.langfusePublicKey, secretKey: config.langfuseSecretKey, baseUrl: config.langfuseHost, }), repair: new ResponseRepair(), config, }); } return agent;}export async function POST(request: NextRequest): Promise<NextResponse> { try { const body: unknown = await request.json(); const result = TenantMessageSchema.safeParse(body); if (!result.success) { return NextResponse.json( { error: "Validation failed", details: result.error.issues }, { status: 400 }, ); } const agentInstance = getAgent(); const agentResponse: AgentResponse = await agentInstance.handleMessage(result.data); return NextResponse.json(agentResponse, { status: 200 }); } catch { return NextResponse.json({ error: "Internal error" }, { status: 500 }); }}export function GET(): NextResponse { return NextResponse.json({ status: "ok" });}
Expected output:curl -X POST http://localhost:3000/api/chat -H 'Content-Type: application/json' -d '{"message":"What is my rent?","tenantId":"tenant-123"}' returns a JSON AgentResponse with a reply from Bedrock.
Step 13: Add instrumentation and health server
Next.js App Router lets you run server-side initialization code via src/instrumentation.ts. The register() function runs on every Node.js server start — it wires all services, creates the KnowledgeAgent, starts an Express health server on port 3001, and schedules session cleanup every 5 minutes.
First, enable the instrumentation hook in next.config.ts:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { experimental: { instrumentationHook: true, },};export default nextConfig;
// src/health-server.tsimport express from "express";export function startHealthServer( port: number, healthCheck: () => Promise<{ ok: boolean; services: Record<string, boolean> }>,): void { const app = express(); app.get("/health", async (_req, res) => { try { const result = await healthCheck(); res.json(result); } catch (err) { res.status(500).json({ ok: false, error: String(err) }); } }); app.listen(port, () => { console.log(`Health server started on port ${String(port)}`); });}
Expected output: When the app starts, curl http://localhost:3001/health returns { "ok": true, "services": { ... } } with the status of every dependency.
Step 14: Run the typecheck, lint, and tests
The project includes a full test suite with vitest, mocks for every external dependency, and 90%+ coverage thresholds. Run the full validation suite:
terminal
pnpm typecheckpnpm lintpnpm test
Expected output:
pnpm typecheck exits 0 (no TypeScript errors)
pnpm lint exits 0 (no ESLint violations)
pnpm test exits 0, reports numFailedTests: 0, numTotalTests >= 3, and coverage thresholds (lines, branches, functions, statements) at 90% or higher on runtime code under src/.
The test files live in tests/ mirroring the source structure:
tests/services/knowledge-agent.test.ts — 26 test cases covering happy path, cache hits, session management, Buildium failures, Bedrock failures, repair failures, non-fatal error recovery, and health checks
tests/services/memory-service.test.ts — extractAndStore, retrieve, runMaintenance, and close
tests/services/cache-service.test.ts — get hit/miss, set, invalidate, connect, healthCheck
tests/lib/config.test.ts — env var validation and caching
tests/api/chat/route.test.ts — integration test for the route handler
Next steps
Add a Bedrock Knowledge Base — configure a Bedrock Knowledge Base with your property documents and use the retrieveAndGenerate method on BedrockClient to add RAG over your document corpus
Add a web UI — build a chat interface in app/page.tsx that calls POST /api/chat and renders the reply, sources, and cost in a tenant-facing portal
Switch to persistent storage — replace InMemoryMemoryStorage with @reaatech/agent-memory-storage-pg or another persistent backend so agent memory survives process restarts
Add rate limiting — wrap the API route with rate-limit middleware that uses the LLM cache’s Qdrant adapter to track per-tenant request counts
Deploy monitoring — integrate the health server’s output into CloudWatch alarms that page when any service (buildium, bedrock, memory, cache, sessions) reports false
"You are a helpful property management assistant. You answer tenant questions about leases, payments, maintenance, and property policies using the context provided. Be concise, accurate, and friendly. If you don't know the answer, say so rather than guessing."