Clarus Heal / ux-struggle-detector
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.
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.
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.
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.
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.
sh_9f3c..." is useless. "The user rage-clicked the Save Tax Settings button on /settings/billing" is actionable. So the frontend is ingested and mapped first.safeModeUntil, invasive intervention types are allowlist-gated, sensitive routes have a denylist, and detection thresholds adapt per element from nightly p95 baselines instead of using one global constant. The baselines may only make a detector less eager, never more: a p95 low enough to compute a rage-click threshold of one click still leaves the static "3 clicks in 2s" rule as the floor, because the calm element is exactly the one where a false positive lands on a consequential screen. That floor is enforced by Math.max against the static rule and has been since the detector was written.
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.
| Path | Lines | What it is |
|---|---|---|
| src/lib/struggle/detect.ts | 1,182 | the 40 detection rules, each a pure function |
| src/lib/parsers/react.ts | 797 | Babel JSX extraction |
| src/app/api/events/route.ts | 639 | ingest, hydrate, detect, dispatch |
| src/lib/parsers/universal-html.ts | 703 | template scan for non-React families |
| prisma/schema.prisma | 666 | 23 models, 10 enums, 4 applied migrations |
| src/sdk/index.ts | 771 | capture loop and init |
| src/sdk/renderers.ts | 719 | 12 intervention renderers |
| src/lib/parsers/registry.ts | 609 | 46 framework entries, 22 families |
| src/lib/interventions/dispatcher.ts | 523 | variant selection and gating |
(orgId, idempotencyKey) unique index plus createMany({ skipDuplicates: true }) means the offline replay buffer can retry as aggressively as it likes with zero server-side dedup logic.EVENT_SCHEMA_VERSION is 3 and versions 1 and 2 are still accepted, so old cached SDK bundles in customers' browsers keep working through a rollout.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.
| Area | Status | Notes |
|---|---|---|
| Repo ingest, URL crawl, SPA crawl | built | Octokit tree and blob API, no git clone |
| Framework detection | built | 46 entries, 22 families, confidence-scored |
| React / Preact AST parsing | built | Babel |
| Other framework parsing | partial | regex template scanner, less accurate than AST |
| StubParser / SoftStubParser | unreachable | present as an escape hatch, but nothing imports them |
| SDK capture | built | 15 event types, scrub, buffer, sampling |
| SDK renderers | partial | 12 of 15 types; TOUR renders as a modal |
| DOM / BEHAVIOR / AUTO_FIX | not built | gated in the dispatcher; nothing rewrites a customer's DOM |
| 40 detection rules + baselines | built | per-element adaptive thresholds; the static rule is a floor, so a baseline can only raise a threshold, never lower it |
| Dispatch, denylist, safe mode | built | bandit with deterministic fallback |
| SaaS shell | built | magic-link auth, onboarding, 11 dashboard pages, metering |
| Event type persistence | partial | SDK emits 15 types, the DB enum stores 7 |
| Integration / E2E tests | not built | unit 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.