Clarus Heal / ux-struggle-detector

Catch users getting stuck, then help them on the spot.

Detect confusion server-side and answer with a hint in the same HTTP response.

It maps a customer's web app UI, either by reading their GitHub repo or by crawling their live site, then watches real users through a drop-in script tag. Struggle detection runs server-side against 40 named rules, and any intervention it decides to show comes back in the same response the events arrived in. The customer never edits their application code to add a hint. A PII scrubber runs in the browser before anything is sent.

40detection rules
46framework entries
25 KBminified SDK
165tests, 10 files
20.5klines of TS/TSX

Three pillars

01

Map the UI first

Framework detection across 46 registry entries in 22 families, a Babel AST parser for React and Preact, a universal template scanner for everything else, then an LLM pass that gives each element a semantic name and intent.

02

Watch from the browser

A dependency-free SDK, about 1,640 lines, capturing 15 event types with client-side PII masking, offline buffering, and uniform, per-type, or predicate-based sampling.

03

Decide and intervene

40 pure detection rules over hydrated session history, then a bandit-driven dispatcher that returns an overlay, tooltip, or hint inline, gated by safe mode and a route denylist.

Why the code looks like this

Analytics and replay tools observe and report. Tour tools intervene, but only where a human already decided in advance that a tour should appear. The gap is between knowing something is wrong and doing something about it while the user is still on the page. Three constraints shaped everything else.

Data flow

customer repo or live URL | v framework detector (46 entries, 22 families) | v Babel AST parser | universal template scan | v UIElement / UIRoute rows -> LLM enrichment (name, intent, copy) | real user's browser | | SDK: 15 event types, PII scrub, | | offline buffer, sampling | v v POST /api/events -> persist (idempotent) -> hydrate session history -> load per-element baselines -> 40-rule detector -> dispatcher (bandit, cache, denylist, safe mode) -> interventions in the SAME response | SDK renders overlay / tooltip / hint | outcome events feed the bandit's next pick

Every extracted element gets a deterministic ID (sh_ plus 32 hex chars) hashed over (orgId, filePath, nodeDescriptor) with the global Web Crypto API, so the identical function runs unmodified in Node build tooling, in edge runtimes, and in the customer's browser. Drift between those three consumers would degrade the system silently rather than failing loudly, which is why the invariant is documented in the source.

Key modules

PathLinesWhat it is
src/lib/struggle/detect.ts1,182the 40 detection rules, each a pure function
src/lib/parsers/react.ts797Babel JSX extraction
src/app/api/events/route.ts639ingest, hydrate, detect, dispatch
src/lib/parsers/universal-html.ts703template scan for non-React families
prisma/schema.prisma66623 models, 10 enums, 4 applied migrations
src/sdk/index.ts771capture loop and init
src/sdk/renderers.ts71912 intervention renderers
src/lib/parsers/registry.ts60946 framework entries, 22 families
src/lib/interventions/dispatcher.ts523variant selection and gating

Details worth a look

Quickstart

Node 20 or newer (CI runs 22) and Postgres. The setup script installs pnpm, installs dependencies, writes .env with generated secrets, and builds the SDK bundle.

./scripts/setup.sh            # macOS / Linux
.\scripts\setup.ps1           # Windows

docker compose up -d          # Postgres + Adminer, skip if you have your own

# the one value you set by hand in .env:
#   DATABASE_URL="postgresql://postgres:postgres@localhost:5432/clarus_heal?schema=public"

pnpm db:migrate
pnpm dev

Installing the SDK on a customer's page is one script tag, or two if you prefer explicit options:

<script src="/sdk.min.js" data-org-id="org_..." data-ingest-key="ck_..."></script>

The production Compose deployment requires authentication and scoped ingestion keys. The guided live lab runs the real collector, detector and intervention renderer locally, with no ingestion server or database. Complete the five-step tour and export its evidence, then follow the SDK integration guide for production.

Longer walkthroughs live in the repo: GETTING_STARTED.md for a step-by-step setup, and GITHUB_SETUP.md for registering the GitHub App, which is only needed for the repo-ingest path.

What is built, what is not

AreaStatusNotes
Repo ingest, URL crawl, SPA crawlbuiltOctokit tree and blob API, no git clone
Framework detectionbuilt46 entries, 22 families, confidence-scored
React / Preact AST parsingbuiltBabel
Other framework parsingpartialregex template scanner, less accurate than AST
StubParser / SoftStubParserunreachablepresent as an escape hatch, but nothing imports them
SDK capturebuilt15 event types, scrub, buffer, sampling
SDK rendererspartial12 of 15 types; TOUR renders as a modal
DOM / BEHAVIOR / AUTO_FIXnot builtgated in the dispatcher; nothing rewrites a customer's DOM
40 detection rules + baselinesbuiltper-element adaptive thresholds; the static rule is a floor, so a baseline can only raise a threshold, never lower it
Dispatch, denylist, safe modebuiltbandit with deterministic fallback
SaaS shellbuiltmagic-link auth, onboarding, 11 dashboard pages, metering
Event type persistencepartialSDK emits 15 types, the DB enum stores 7
Integration / E2E testsnot builtunit tests only, no Postgres container in CI

This has never been deployed publicly. No customers, no traffic, no revenue. Every number on this page comes from the code and the test suite.