When you build tools for mental health, everyone assumes you need a machine learning model. A sentiment analyzer. A BERT classifier. An API call to OpenAI.
You don't.
I built a text classifier that detects 11 categories of biased thinking — with zero dependencies, zero API calls, and zero ML models. It runs entirely in the browser. It's accurate enough to be useful, fast enough to feel instant, and small enough to fit in a single HTML file.
This is how it works, and why "just use pattern matching" is a legitimate architecture decision — not a shortcut.
The problem
In cognitive behavioral therapy (CBT), a "cognitive distortion" is a habitual thinking trap — catastrophizing ("this is a disaster"), all-or-nothing thinking ("I'm a total failure"), mind-reading ("they think I'm stupid"), and so on. There are 11 classic ones.
A distortion detector takes a free-text thought like:
"I made one typo in the PR. They probably think I'm incompetent. I should just quit."
…and tells you which distortions are present (here: mind-reading, should statements, labeling) and generates a balanced reframe for each.
Try it live: Cognitive Distortion Checker
Why not ML?
I considered it. Here's why I didn't:
- The domain is constrained. There are 11 categories with known, stable vocabulary. "Catastrophizing" has been defined the same way since Burns (1980). The keyword space is small and doesn't drift.
- Precision matters more than recall. A false positive ("you're catastrophizing!" when you're not) undermines trust in a mental health tool. Pattern matching with confidence thresholds gives conservative, explainable results. A neural net gives you a probability you can't reason about.
- Zero-latency, zero-cost, zero-privacy-risk. No API call, no model download, no data leaving the browser. For a tool where people type their most private thoughts, this is non-negotiable.
- Explainability is the feature. When the tool says "detected: catastrophizing — matched keywords: 'disaster', 'ruined'", the user sees why. That transparency is therapeutically valuable. A black-box classifier can't do this.
This is the key insight: for a constrained domain with a stable, known vocabulary, pattern matching isn't a poor man's ML — it's the correct architecture.
The data structure
The entire classifier is a JavaScript array of objects. Each distortion has an id, a human-readable name, a desc, an array of patterns (the keywords), and a reframe (the generated response):
const DISTORTIONS = [
{
id: "allornothing",
name: "All-or-Nothing Thinking",
desc: "You see things in black-and-white. If it's not perfect, it's a total failure.",
patterns: ["always", "never", "completely", "totally", "perfect",
"complete failure", "total failure", "useless", "worthless",
"100%", "all or nothing"],
reframe: "Is there a middle ground? What counts as 'good enough' here? " +
"One mistake doesn't cancel everything else that went well."
},
{
id: "catastrophizing",
name: "Magnification / Catastrophizing",
desc: "You exaggerate how bad things are.",
patterns: ["terrible", "awful", "horrible", "disaster", "catastrophe",
"ruined", "end of the world", "worst ever", "can't cope",
"unbearable"],
reframe: "On a 0-10 scale, how bad is this really? Will it matter in a " +
"week? A year? You've coped with hard things before."
},
{
id: "mindreading",
name: "Jumping to Conclusions (Mind Reading)",
desc: "You assume others are reacting negatively with no evidence.",
patterns: ["they think", "he thinks", "she thinks", "they're judging",
"probably thinks", "must think", "thinks i'm"],
reframe: "What's the actual evidence they think that? Could there be " +
"another explanation for their behavior? Have you asked?"
},
// ... 8 more distortions (overgeneralization, mental filter,
// disqualifying the positive, fortune telling, emotional reasoning,
// should statements, labeling, personalization)
];
That's the entire "model." 11 objects, ~10 keywords each, ~110 patterns total. No training data, no epochs, no GPU.
The detection algorithm
Here's the complete classifier. It's 15 lines:
function analyze(text) {
text = text.trim().toLowerCase();
let found = [];
for (const d of DISTORTIONS) {
let matches = [];
for (const p of d.patterns) {
if (text.includes(p)) matches.push(p);
}
if (matches.length > 0) {
let confidence = matches.length >= 3 ? "high"
: matches.length === 2 ? "med"
: "low";
found.push({ ...d, matches, confidence });
}
}
// Sort: high confidence first
const order = { high: 0, med: 1, low: 2 };
found.sort((a, b) => order[a.confidence] - order[b.confidence]);
return found;
}
That's it. Lowercase the input, check which patterns are substrings, count matches, assign confidence by match count, sort by confidence.
Why includes() and not regex? I started with regex. It was harder to maintain (escaping, word boundaries), harder to debug (which pattern matched?), and no more accurate for this vocabulary. String.includes() is readable, fast, and the matched keyword is right there — which I display to the user for explainability.
Confidence scoring
Three tiers, by match count:
| Matches | Confidence | Rationale |
|---|---|---|
| 1 | low | Could be a false positive ("never" in "never mind") |
| 2 | med | Two signals — likely real |
| 3+ | high | Multiple keywords — almost certainly this distortion |
This is deliberately conservative. A single keyword match is flagged "low" and sorted last. The user sees the confidence label and can judge for themselves. Conservative + explainable > aggressive + black-box.
The reframe generation
Each distortion carries its own reframe — a Socratic question that prompts the user to examine the thought. This isn't generated by an LLM; it's authored, reviewed, and static:
// Catastrophizing reframe:
"On a 0-10 scale, how bad is this really? Will it matter in a week? " +
"A year? You've coped with hard things before."
// Should statements reframe:
"Replace 'I should...' with 'I choose to...' or 'I'd prefer to...'. " +
"What happens if you don't? Is this rule yours or someone else's?"
Could an LLM generate better, more contextual reframes? Probably. Would it add 300ms latency, an API dependency, a privacy risk, and a cost per call? Yes. For a free tool processing private thoughts, static authored reframes are the right tradeoff. The 11 reframes took me an afternoon to write and will never need retraining.
What I'd do differently with more scale
Pattern matching breaks down when:
- The vocabulary drifts. Slang, new expressions, multilingual input. Then you'd need embeddings or a fine-tuned classifier.
- You need nuance. "I'm always late" (overgeneralization) vs "I'm always learning" (positive). Pure keyword matching can't tell. You'd add negation handling or a small classifier on top.
- The category count grows. 11 categories with ~10 keywords each is manageable. 100 categories with overlapping vocabulary would need disambiguation.
For 11 stable categories in a constrained clinical vocabulary, none of these apply. Knowing when your problem is simple enough for pattern matching is a skill.
The full architecture
Every tool in the CBT toolkit uses the same pattern:
┌─────────────────────────────────────────┐
│ Single HTML file (no build step) │
│ ├── <style> (CSS, no framework) │
│ ├── <script> (vanilla JS) │
│ │ ├── DISTORTIONS[] (the "model") │
│ │ ├── analyze() (the classifier) │
│ │ ├── render() (DOM updates) │
│ │ └── save() (localStorage) │
│ └── JSON-LD (schema.org structured) │
└─────────────────────────────────────────┘
- No npm, no bundler, no framework. Each page is self-contained. I can open the file directly in a browser and it works.
- localStorage for persistence. Entries saved to the browser. No server, no database, no signup. Export to JSON anytime.
- Aggregate insights computed client-side. Each tracker computes trends from logged entries — distress over time, recovery rate, most common triggers. All in vanilla JS, all from localStorage.
-
Schema.org JSON-LD for SEO. Each page declares its type (
WebApplication,Article,FAQPage) so search engines understand the content.
The results
I've built 23 of these tools — one for each major CBT application (anxiety, depression, OCD, burnout, imposter syndrome, perfectionism, panic attacks, and 16 more). Each uses the same distortion detector at its core, with condition-specific keywords and reframes layered on top.
The distortion checker alone handles inputs like:
Input: "I'm a complete failure, they probably think I'm stupid,
I should just give up"
Output: 3 distortions detected (high confidence)
1. Labeling — matched: "i'm a", "complete failure"
2. Mind Reading — matched: "they think", "thinks i'm", "probably think"
3. Should Stmt — matched: "should"
+ 3 reframes generated
Total code: ~200 lines. Total dependencies: 0. Total API calls: 0. Total cost to run: $0.
Takeaways for builders
- Match your architecture to your problem, not to what's impressive. A 110-keyword pattern matcher is the correct solution for 11 stable categories. Reaching for BERT here would be over-engineering.
- Explainability can be the feature, not a constraint. Showing which keywords matched is more useful than a confidence score from a black box — especially in a therapeutic context.
-
Zero-dependency tools are underrated. No build step, no supply chain, no breaking changes, no
npm audit. A single HTML file that works forever. - Constrained domains are everywhere. Form validation, command parsing, intent detection in a small skill set, content moderation for a known category list — all pattern-matching problems dressed up as ML problems.
If you want to see the full code or use the tools:
- Cognitive Distortion Checker — the tool this article is about
- CBT Toolkit Hub — all 23 tools, each self-contained
- Interactive Thought Record — the full 7-step CBT exercise
If you prefer a structured Notion template for tracking your thoughts (the "paper notebook" version of these tools), I made one: CBT Thought Record for Notion ($7).
What's a problem you're solving with ML that might just be a pattern-matching problem in disguise?
Top comments (0)