A full-stack prototype investment advisor onboarding platform with AI-powered KYC document ingestion, portfolio suitability analysis, and AML/sanctions screening. Built for demonstration purposes against a company like Osaic.
- Overview
- Tech Stack
- Prerequisites
- Environment Variables
- Installation
- Running Locally
- Building for Production
- Project Structure
- Features
- AML Screening
- Known Issues & Dependency Notes
- Database
- Deployment Notes
Nexus Advisor is a single-binary fullstack web application: an Express backend serves both the REST API and the Vite-compiled React frontend from the same port. Data is persisted in a local SQLite file (osaic.db). All AI features call the Anthropic API server-side — no credentials are exposed to the browser.
The app ships with five seed clients pre-populated in the database so you can explore every feature immediately after starting the server.
| Layer | Technology |
|---|---|
| Frontend | React 18, Vite 7, Tailwind CSS 3, shadcn/ui, TanStack Query v5, Wouter |
| Backend | Express 5, TypeScript, tsx (dev) / esbuild (prod) |
| Database | SQLite via better-sqlite3 + Drizzle ORM |
| AI | Anthropic SDK (claude-sonnet-4-6) |
| Forms | react-hook-form + Zod |
| Routing | Wouter with useHashLocation (hash-based, iframe-safe) |
Required: Node.js 20.x
The project pins @types/node to 20.19.27 and uses import.meta.dirname (Node 20.11+). Node 18 will likely work for runtime but may produce TypeScript errors. Node 22+ is untested and may surface ESM/CJS interop issues with better-sqlite3.
node --version # must be >= 20.11
npm --version # npm 10+ recommendednvm users:
nvm use 20before installing.
Not required at runtime, but node-gyp (a transitive dependency of better-sqlite3) may invoke Python 2/3 during a native compile step on some platforms. If npm install fails with a node-gyp error, install Python 3 and try again.
better-sqlite3 ships pre-built binaries for common platforms (Linux x64, macOS arm64/x64, Windows x64). If a pre-built binary is unavailable for your platform, npm install will attempt to compile from source, which requires:
- macOS: Xcode Command Line Tools (
xcode-select --install) - Linux:
build-essentialandpython3(apt install build-essential python3) - Windows:
windows-build-tools(npm install -g windows-build-tools) or Visual Studio Build Tools
All AI features (document extraction, KYC gap analysis, suitability check, AML screening, chat) require a valid Anthropic API key with access to claude-sonnet-4-6. Without it the server will start but every AI endpoint will return a 500 error.
Create a .env file in the project root (same level as package.json):
# Required — Anthropic API key for all AI features
ANTHROPIC_API_KEY=sk-ant-...
# Optional — defaults to 'development' if unset
NODE_ENV=developmentThe server loads .env automatically via the dotenv package. Never commit your .env file.
Note on the Anthropic model: The app calls
claude-sonnet-4-6. If that model ID is deprecated or renamed in your account, update the string inserver/routes.ts(search forclaude-sonnet-4-6; it appears in 5 places).
# Clone / unzip the source
cd osaic-kyc
# Install all dependencies (runs node-gyp for better-sqlite3 if needed)
npm installwhen you try npm run dev if you get an error about missing the module @rollup/rollup-win32-x64-msvc you can resolve that with
npm i @rollup/rollup-win32-x64-msvcIf npm install fails on better-sqlite3 with a native compile error, try:
npm install --ignore-scripts
npm rebuild better-sqlite3If it still fails, confirm your Node version matches the pre-built binary targets:
node -e "console.log(process.versions)"npm run devThis starts a single process that:
- Runs the Express API on port 5000
- Serves the Vite dev server (with HMR) proxied through Express
Open http://localhost:5000 in your browser.
The SQLite database file (osaic.db) is created automatically on first start, along with all tables and seed data. You do not need to run any migration commands.
Port conflict: If port 5000 is in use, kill the occupying process or change the port in
server/index.ts.
npm run buildThis runs script/build.ts which:
- Compiles the React frontend with Vite →
dist/public/ - Bundles the Express backend with esbuild →
dist/index.cjs
Start the production server:
NODE_ENV=production ANTHROPIC_API_KEY=sk-ant-... node dist/index.cjsOr with a .env file present:
npm startThe production server serves the compiled frontend as static files and handles API requests — everything on port 5000, no separate frontend process.
osaic-kyc/
├── client/ # React frontend (Vite root)
│ └── src/
│ ├── components/
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── AppShell.tsx # Sidebar nav + layout wrapper
│ │ └── StatusBadge.tsx
│ ├── pages/
│ │ ├── Dashboard.tsx
│ │ ├── ClientDirectory.tsx
│ │ ├── ClientDetail.tsx # KYC status + AML gate
│ │ ├── KycOnboarding.tsx # 5-step wizard incl. AML
│ │ ├── PortfolioOnboarding.tsx
│ │ ├── DocumentVault.tsx
│ │ └── AmlScreening.tsx # Full AML screening page
│ ├── lib/
│ │ └── queryClient.ts # apiRequest + TanStack Query setup
│ ├── hooks/
│ │ └── use-toast.ts
│ ├── App.tsx # Wouter router
│ ├── index.css # CSS variables + Tailwind directives
│ └── main.tsx
├── server/
│ ├── index.ts # Express app entry + dev/prod branching
│ ├── routes.ts # All REST + AI endpoints
│ ├── storage.ts # SQLite DDL, seed data, all CRUD methods
│ ├── vite.ts # Vite middleware (dev only)
│ └── static.ts # Static file serving (prod only)
├── shared/
│ └── schema.ts # Drizzle schema + Zod insert schemas + types
├── script/
│ └── build.ts # esbuild + Vite production build script
├── dist/ # Production build output (git-ignored)
├── osaic.db # SQLite database (git-ignored, auto-created)
├── drizzle.config.ts
├── vite.config.ts
├── tailwind.config.ts
├── tsconfig.json
└── package.json
Real-time KYC pipeline stats, AUM summary, recent activity feed, and quick-action shortcuts.
Searchable, filterable table of all clients with KYC and portfolio status badges.
- Personal Info — name, email, DOB, SSN
- Address & Financials — address, risk tolerance, income, net worth, investment objective
- Documents — upload identity/financial/legal documents with AI field extraction
- AML Screening — AI-powered sanctions and compliance check (see below); blocks progression if auto-blocked
- Review & Submit — summary with AML verdict inline, creates or updates client record
- KYC status history and manual status transitions
- AML screening result panel with compliance override workflow
- AI KYC gap analysis
- Linked portfolio summary
- Document list
- Household and account structure
- AI suitability check against client's risk profile
- Holdings entry
- Cross-client document browser with category filters
- Per-document AI field extraction
Context-aware chat embedded in the app shell — can answer questions about any loaded client or document.
The AML layer calls claude-sonnet-4-6 with a structured compliance prompt that evaluates five checks per client:
| Check | What it looks for |
|---|---|
| OFAC / SDN | US Treasury Specially Designated Nationals list |
| PEP Status | Politically Exposed Persons (Class 1–3) |
| Adverse Media | Criminal charges, fraud, regulatory actions |
| Sanctions | UN, EU, OFAC non-SDN lists |
| Source of Wealth | Unexplained wealth origins or red flags |
Risk levels:
| Score | Level | Consequence |
|---|---|---|
| 0–15 | Clear | No restrictions |
| 16–35 | Low | No restrictions |
| 36–60 | Medium | Advisory review recommended |
| 61–84 | High | requiresOverride=true — compliance officer must document justification before KYC can be approved |
| 85–100 | Blocked | autoBlocked=true — KYC approval button is hard-locked; OFAC SDN match requires CCO-level review |
Screening results are persisted in the screening_results table and surfaced in:
- The KYC wizard (step 4)
- The Client Detail page (AML panel + gated approve button)
- The standalone AML Screening page (Compliance → AML Screening in the sidebar)
Quick Screen: The AML Screening page includes a standalone name/DOB form for ad-hoc checks without needing a client record.
This is the most likely source of installation failures.
- ARM Linux (e.g., Raspberry Pi, some Docker images): No pre-built binary exists.
npm installwill attempt a source compile. Ensurebuild-essential,python3, and a compatiblegccare present. - Apple Silicon (M1/M2/M3): Pre-built arm64 binaries are included in recent
better-sqlite3versions. If you see anarchmismatch error (e.g., running an x64 Node binary under Rosetta), switch to an arm64 Node build vianvm install 20 --default. - Windows: Requires Visual Studio Build Tools or
windows-build-tools. Run PowerShell as Administrator:npm install -g windows-build-tools. Node 20 + better-sqlite3 11.x is tested on Windows x64. - Docker/CI: Use
node:20-bullseyeornode:20-bookwormbase images which include the necessary build toolchain. Alpine images requireapk add python3 make g++beforenpm install.
The project uses Tailwind CSS v3 (tailwindcss: ^3.4.17) with @tailwindcss/vite listed in devDependencies. If you add a package that transitively installs tailwindcss@^4.x, it will conflict with the v3 PostCSS setup. Lock Tailwind to v3 in package.json if this happens:
"overrides": {
"tailwindcss": "3.4.17"
}Also: the index.css uses @tailwind base/components/utilities directives — not the Tailwind v4 @import "tailwindcss" syntax. Do not upgrade to v4 without rewriting index.css and tailwind.config.ts.
The project uses express@^5.0.1. Express 5 changes error-handler signatures and route parameter handling. If you add middleware written for Express 4 (e.g., many community packages), watch for async error propagation differences — Express 5 automatically catches rejected promises in route handlers, so you may not need try/catch in every route.
uuid v14 is ESM-only. The backend is compiled to CJS by esbuild at build time, which handles the interop automatically. If you add scripts that import uuid directly outside of the esbuild pipeline (e.g., a plain node script), use a dynamic import() or switch to crypto.randomUUID() (available natively in Node 15.6+).
TanStack Query v5 dropped the positional argument form. Every useQuery and useMutation call must use the object form:
// ✅ v5
useQuery({ queryKey: ['/api/clients'], queryFn: ... })
// ❌ v4 (will throw at runtime)
useQuery(['/api/clients'], ...)If you copy query patterns from older tutorials or Stack Overflow, update them.
The frontend uses useHashLocation from wouter/use-hash-location. All routes are hash-based (/#/clients, /#/screening). This is intentional — the app is served from a static S3 prefix where path-based routing would 404 on deep-link refreshes.
Do not use href="#section" anchor links for in-page scroll navigation — Wouter intercepts the hash change and treats it as a route. Use onClick + scrollIntoView() instead.
The npm run dev and npm start scripts use cross-env to set NODE_ENV cross-platform. This is already wired in package.json — no extra steps needed. If you cloned an older copy of this repo that predates this fix (scripts looked like NODE_ENV=development tsx ...), you will get:
'NODE_ENV' is not recognized as an internal or external command
Fix it by running npm install (which picks up cross-env) and confirming the scripts in package.json are prefixed with cross-env.
The template explicitly avoids browser storage APIs because the original deployment target is a sandboxed iframe environment where they are blocked. All transient state uses React state; all persistent state goes through the Express API to SQLite. Do not add localStorage calls — they will silently fail or throw in sandboxed contexts.
The SDK uses streaming-capable message creation. The app uses the non-streaming messages.create() call. If you want to add streaming responses (e.g., for the chat endpoint), use the stream() helper or set stream: true and handle the SSE response. Be aware that Express 5 + SSE requires manually setting res.flushHeaders() and managing connection teardown.
drizzle.config.ts points to ./data.db (the template default), but the runtime storage code in server/storage.ts uses new Database("osaic.db"). If you run npm run db:push it will create a separate data.db file that the running server will never read. The app does not need db:push — tables are created via raw sqlite.exec() DDL in storage.ts on startup. Only use drizzle-kit if you want to inspect the schema with Drizzle Studio; point it at osaic.db first:
// drizzle.config.ts
dbCredentials: { url: "./osaic.db" }SQLite file: osaic.db in the project root.
Tables (all created automatically on first server start):
| Table | Description |
|---|---|
clients |
Core client profiles, KYC status, financial info |
documents |
Uploaded documents with AI-extracted fields (stored as JSON text) |
portfolios |
Portfolio accounts with holdings (stored as JSON text) |
chat_messages |
Chat history, optionally scoped to a clientId |
activity_log |
Audit trail of advisor actions |
screening_results |
AML/sanctions screening records with per-check JSON results |
Seed data (5 clients) is inserted on first start only — it is skipped if clients already exist, so re-starts are safe.
To reset the database entirely:
rm osaic.db
npm run dev # re-creates and re-seedsThe production build produces two artifacts:
dist/public/— compiled static frontend (HTML, JS, CSS)dist/index.cjs— bundled Express server
These must be deployed together. The frontend's apiRequest helper rewrites API URLs using a __PORT_5000__ token at deploy time (used by the original Perplexity Computer hosting environment). In a standard deployment (e.g., a VPS or container), this token will not be substituted and API calls will use relative paths (/api/...), which is correct behavior when the Express server also serves the frontend.
Recommended self-hosted setup:
[ nginx / reverse proxy ] → port 443 (HTTPS)
↓
[ node dist/index.cjs ] → port 5000 (internal)
↓
[ osaic.db ] → local file, same directory
Set ANTHROPIC_API_KEY in your process environment or a .env file co-located with dist/index.cjs. The dotenv package is bundled and will load it automatically.
The SQLite database file path is relative to the current working directory at server start. Run the server from the directory containing osaic.db, or set an absolute path in server/storage.ts:
// server/storage.ts line 15 — change to absolute path for robustness
const sqlite = new Database("/var/data/osaic.db");