Property managers and tenants spend hours digging through Buildium’s portal for simple answers on lease terms, rent balances, and maintenance history, leading to frustration and support bottlenecks.
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 answers property management questions about leases, tenants, work orders, and maintenance schedules. You’ll connect a Next.js API route to a RAG pipeline powered by Voyage AI embeddings, pgvector similarity search, and xAI Grok, orchestrated by the @reaatech agent framework. The agent syncs data from Buildium’s REST API and maintains conversational context across multi-turn queries.
Prerequisites
Node.js 22 or later (check with node --version)
pnpm 10 (install with npm install -g pnpm@10)
A PostgreSQL database with the vector extension (pgvector)
An xAI API key (for Grok inference)
A Voyage AI API key (for embeddings)
Buildium API credentials (client ID and client secret)
Familiarity with TypeScript and Next.js App Router patterns
Step 1: Scaffold the project and install dependencies
Create a new Next.js project with TypeScript and the App Router, then install all required packages at exact pinned versions.
Also update .env.example with the same placeholders so other developers know what to fill in:
terminal
cat > .env.example << 'ENVEOF'# Env vars used by xai-grok-knowledge-agent-for-buildium-property-management-queries.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentDATABASE_URL=postgres://user:***@host:5432/dbnameXAI_API_KEY=<your-xai-api-key>XAI_MODEL_ID=grok-3VOYAGE_API_KEY=<your-voyage-api-key>BUILDIUM_CLIENT_ID=<your-buildium-client-id>BUILDIUM_CLIENT_SECRET=<your-buildium-client-secret>BUILDIUM_API_BASE_URL=https://api.buildium.comBUILDIUM_SYNC_INTERVAL_MINUTES=60VECTOR_DIMENSION=1024SESSION_MAX_TOKENS=4096ENVEOF
Expected output:
.env.local and .env.example exist in the project root
Each @reaatech package reads its configuration from process.env references
Step 3: Set up the database connection and migration runner
Create a module that connects to PostgreSQL via the postgres library and runs migrations on startup.
ts
// src/lib/db.tsimport postgres from "postgres";import { runMigrations } from "../db/migrations/index.js";let sql: postgres.Sql | null = null;export async function getDb(): Promise<postgres.Sql> { if (!sql) { sql = postgres(process.env.DATABASE_URL ?? ""); await runMigrations(sql); } return sql;}export async function closeDb(): Promise<void> { if (sql) { await sql.end({ timeout: 5 }); sql = null; }}
Create a thin re-export for pgvector’s toSql helper. This keeps the import path clean and makes mocking easier in tests.
getDb() returns a singleton postgres.Sql connection, lazily initialized
closeDb() ends the connection pool and resets the singleton
runMigrations is called once on first connection
Step 4: Write database migrations
You need eight migrations that build the schema from scratch — vector extension, documents, document chunks, sync metadata, sessions, messages, cost spans, and the HNSW index.
Start with a migration runner that tracks applied migrations in a _migrations table:
ts
// src/db/migrations/index.tsimport type { Sql } from "postgres";const migrations = [ { id: "001", name: "create_vector_extension", file: () => import("./001_create_vector_extension.js") }, { id: "002", name: "create_documents_table", file: () => import("./002_create_documents_table.js") }, { id: "003", name: "create_document_chunks_table", file: () => import("./003_create_document_chunks_table.js") }, { id: "004", name: "create_sync_metadata_table", file: () => import("./004_create_sync_metadata_table.js") }, { id: "005", name: "create_sessions_table", file: () => import("./005_create_sessions_table.js") }, { id: "006", name: "create_messages_table", file: () => import("./006_create_messages_table.js") }, { id: "007", name: "create_cost_spans_table", file: () => import("./007_create_cost_spans_table.js") }, { id: "008", name: "create_hnsw_index", file: () => import("./008_create_hnsw_index.js") },];export async function runMigrations(sql: Sql): Promise<void> { await sql` CREATE TABLE IF NOT EXISTS _migrations ( id TEXT PRIMARY KEY, name TEXT NOT NULL, applied_at TIMESTAMPTZ DEFAULT now() ) `; for (const m of migrations) { const existing = await sql`SELECT id FROM _migrations WHERE id = ${m.id}`; if (existing.length > 0) continue; const mod = await m.file(); await mod.up(sql); await sql`INSERT INTO _migrations (id, name) VALUES (${m.id}, ${m.name})`; }}
Now create each migration file. The vector extension enables pgvector:
ts
// src/db/migrations/001_create_vector_extension.tsimport type { Sql } from "postgres";export async function up(sql: Sql): Promise<void> { await sql`CREATE EXTENSION IF NOT EXISTS vector`;}
The documents table stores synced Buildium records:
ts
// src/db/migrations/002_create_documents_table.tsimport type { Sql } from "postgres";export async function up(sql: Sql): Promise<void> { await sql` CREATE TABLE IF NOT EXISTS documents ( id TEXT PRIMARY KEY, source_type TEXT NOT NULL, source_id TEXT NOT NULL, title TEXT, content TEXT NOT NULL, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ) `;}
The document chunks table holds embedded text chunks with full 1024-dimension vectors:
ts
// src/db/migrations/003_create_document_chunks_table.tsimport type { Sql } from "postgres";export async function up(sql: Sql): Promise<void> { await sql` CREATE TABLE IF NOT EXISTS document_chunks ( id TEXT PRIMARY KEY, document_id TEXT REFERENCES documents(id) ON DELETE CASCADE, chunk_index INTEGER NOT NULL, content TEXT NOT NULL, embedding vector(1024), token_count INTEGER, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT now() ) `;}
The sync metadata table tracks when each entity type was last refreshed from Buildium:
ts
// src/db/migrations/004_create_sync_metadata_table.tsimport type { Sql } from "postgres";export async function up(sql: Sql): Promise<void> { await sql` CREATE TABLE IF NOT EXISTS sync_metadata ( entity_type TEXT PRIMARY KEY, last_sync_at TIMESTAMPTZ NOT NULL ) `;}
Sessions and messages store conversation history for multi-turn continuity:
ts
// src/db/migrations/005_create_sessions_table.tsimport type { Sql } from "postgres";export async function up(sql: Sql): Promise<void> { await sql` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT, status TEXT DEFAULT 'active', metadata JSONB DEFAULT '{}', token_count INTEGER DEFAULT 0, message_count INTEGER DEFAULT 0, version INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now(), last_activity_at TIMESTAMPTZ DEFAULT now(), expires_at TIMESTAMPTZ ) `;}
ts
// src/db/migrations/006_create_messages_table.tsimport type { Sql } from "postgres";export async function up(sql: Sql): Promise<void> { await sql` CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE, role TEXT NOT NULL, content TEXT NOT NULL, token_count INTEGER, sequence INTEGER, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT now() ) `;}
Cost spans let you track LLM spending per provider, model, and feature:
ts
// src/db/migrations/007_create_cost_spans_table.tsimport type { Sql } from "postgres";export async function up(sql: Sql): Promise<void> { await sql` CREATE TABLE IF NOT EXISTS cost_spans ( id TEXT PRIMARY KEY, provider TEXT NOT NULL, model TEXT NOT NULL, input_tokens INTEGER NOT NULL, output_tokens INTEGER NOT NULL, cost_usd NUMERIC(10,6) NOT NULL, tenant TEXT DEFAULT 'default', feature TEXT, timestamp TIMESTAMPTZ DEFAULT now() ) `;}
Finally, the HNSW index powers fast cosine-similarity searches:
ts
// src/db/migrations/008_create_hnsw_index.tsimport type { Sql } from "postgres";export async function up(sql: Sql): Promise<void> { await sql` CREATE INDEX IF NOT EXISTS idx_document_chunks_embedding ON document_chunks USING hnsw (embedding vector_cosine_ops) `;}
Expected output:
On the first call to getDb(), all 8 migration files run in order
A _migrations tracking table records which migrations have been applied
similaritySearch returns results ordered by cosine distance (closest first)
topK is clamped between 1 and 20; passing 0 yields 1 result
upsertDocument uses ON CONFLICT — re-syncing the same document updates it in place
Step 7: Create the RAG retrieval service
This service ties the embedding service and vector store together into a single retrieval interface. It returns both raw results and a concatenated context string for the LLM.
retrieveWithContext("What is the lease end date?") returns top-5 matching chunks and a combined context string
An empty query or failed embedding returns empty results (no crash)
Context longer than 3000 characters is truncated with a [truncated] suffix
Step 8: Implement the Buildium API client
The client authenticates with Buildium’s OAuth2 endpoint using client-credentials flow, then fetches leases, tenants, work orders, and properties from the REST API.
getLeases(), getTenants(), getWorkOrders(), getProperties() each return typed arrays
On a 401, the client invalidates the cached token and retries automatically
404 returns an empty array instead of throwing
Step 9: Build the data sync service
The sync service pulls data from Buildium, transforms each entity into a document, chunks and embeds the content, then stores everything in the vector database.
ts
// src/lib/buildium-sync.tsimport { type Document } from "@reaatech/hybrid-rag";import { ChunkingStrategy } from "@reaatech/hybrid-rag";import { TextPreprocessor, DocumentValidator, chunkDocument } from "@reaatech/hybrid-rag-ingestion";import { BuildiumClient, type Lease, type Tenant, type WorkOrder, type Property } from "./buildium/client.js";import { VoyageEmbeddingService } from "./embeddings.js";import { getDb } from "./db.js";import { PgVectorStore } from "./vector-store.js";const preprocessor = new TextPreprocessor({ normalizeUnicode: true, normalizeWhitespace:
Expected output:
syncAll() fetches all entity types from Buildium, embeds them, and stores the results
Errors from individual API calls or embedding failures are collected in result.errors without aborting the entire sync
syncSingleEntity("leases", "1") syncs only one lease by ID
getLastSyncTimestamp() returns the timestamp of the most recent sync across all entity types
Step 10: Build cost telemetry and the xAI Grok LLM router
The cost telemetry service records each LLM call’s token usage and checks a daily budget before allowing new requests. The LLM router picks xAI Grok, injects retrieved context, and optionally includes conversation history.
routeWithBudgetCheck("What is my rent?", "context text") calls Grok and returns the generated answer
If the daily budget is exceeded, it returns a polite refusal without calling the API
Each successful call is recorded in the cost_spans table
On API failure, a user-friendly fallback message is returned instead of crashing
Step 11: Set up session continuity with Postgres storage
Session continuity preserves multi-turn conversation history. You’ll implement the IStorageAdapter interface from @reaatech/session-continuity backed by your PostgreSQL tables.
ts
// src/lib/session-service.tsimport { SessionManager, type IStorageAdapter, type TokenCounter, type Message, ConcurrencyError, type Session, type SessionId, type SessionFilters, type UpdateSessionOptions, type MessageQueryOptions, type HealthStatus, type SessionMetadata, type MessageRole, type MessageContent, type MessageMetadata, type SessionStatus } from "@reaatech/session-continuity";import { getDb } from "./db.js";export class PostgresStorageAdapter implements IStorageAdapter { async createSession(session: Omit<Session, 'id' | 'createdAt' | 'lastActivityAt'
Expected output:
getSessionManager() returns a singleton SessionManager that persists to PostgreSQL
Sessions track message history across turns, with budget-based compression
The PostgresStorageAdapter handles optimistic locking via version fields
Step 12: Build the agent orchestrator with @reaatech/agent-mesh
The orchestrator ties together retrieval, LLM routing, session continuity, and cost tracking into a single handleQuery method. It validates incoming requests with @reaatech/agent-mesh schemas.
POST /api/chat with {"input": "Show me active leases"}
returns {"content": "...", "sessionId": "...", "sources": ["buildium/leases"]}
POST /api/chat with a non-object body returns { "error": "Request body required" } with status 400
GET /api/chat returns { "status": "ok" }
On startup, the register() function initializes the database connection and applies migrations
Step 14: Update the entry point and run the tests
Replace src/index.ts with exports for all public services and types:
ts
// src/index.tsexport { VoyageEmbeddingService } from "./lib/embeddings.js";export { BuildiumClient } from "./lib/buildium/client.js";export { BuildiumSyncService } from "./lib/buildium-sync.js";export { RagRetrievalService } from "./lib/rag-retrieval.js";export { PgVectorStore } from "./lib/vector-store.js";export { getSessionManager } from "./lib/session-service.js";export { CostTelemetryService } from "./lib/cost-telemetry.js";export { LlmRouterService } from "./lib/llm-router.js";export { KnowledgeAgentOrchestrator } from "./lib/agent-orchestrator.js";export const SCAFFOLD_VERSION = "0.1.0" as const;export { type Document, type Chunk, type RetrievalResult, type HybridResult } from "@reaatech/hybrid-rag";export { type IncomingRequest, type AgentResponse, type ContextPacket } from "@reaatech/agent-mesh";
Now configure vitest in vitest.config.ts and set up MSW handlers for the external APIs (Buildium, Voyage AI, xAI):
pnpm test — 116 tests pass, coverage meets the 90% threshold across all four metrics (lines, branches, functions, statements)
Next steps
Wire up a scheduled sync job (cron or setInterval) that calls BuildiumSyncService.syncAll() every hour to keep the vector store fresh
Add a frontend chat widget in app/chat/page.tsx that calls the API route and displays answers with source citations
Extend the orchestrator with specialized agent sub-flows for maintenance escalation, payment reminders, or lease-renewal notifications using @reaatech/agent-mesh workflows
await
getDb
();
const embeddingSql = pgvector.toSql(chunk.embedding) as string;
await sql`
INSERT INTO document_chunks (id, document_id, content, embedding, token_count, metadata)
content: `Lease for unit ${lease.unit} at property ${lease.propertyId}. Status: ${lease.status}. Period: ${lease.startDate} to ${lease.endDate}. Monthly rent: $${String(lease.monthlyRent)}.`,