When you build anything that reads someone's thoughts and tries to find their deepest belief, the assumption is immediate: you need an LLM. A sentiment model. A therapist chatbot. Something that "understands" language at a semantic level.
You don't.
I built a tool that takes a passing thought — "I made a typo in the demo" — and drills all the way down to the core belief driving it — "I'm incompetent" → "I'm worthless" — using a 60-year-old clinical technique encoded as a two-level keyword mapper. Zero dependencies, zero API calls, zero ML. It runs in the browser, on private thoughts, with no data leaving the device.
This is how the "downward arrow technique" becomes 120 lines of JavaScript, and why a rule-based drill beats a language model for finding the beliefs that actually run someone's life.
The problem
In CBT, the thoughts you notice are surface thoughts — the tip of the iceberg. The ones that matter are core beliefs: the deep, often-unspoken rules you live by ("I'm worthless", "I'm unlovable", "I'm incompetent"). Surface thoughts are just core beliefs activated by a trigger.
A core belief detector takes:
"I made a typo in front of the client. They probably think I don't know what I'm doing."
…and drills down:
Surface: "I made a typo → they think I don't know what I'm doing"
Intermediate: "I'm incompetent at my job"
Core: "I'm worthless"
That chain is the whole point. The typo isn't the problem. "I'm worthless" is the belief that makes a typo feel like catastrophe. You can't reframe what you can't name.
Try it live: CBT for Low Self-Esteem — Core Belief Tracker
The technique: the downward arrow
The downward arrow technique is a real CBT method (Burns, 1980s). The therapist asks: "If that thought were true, what would it mean about you?" — repeatedly — until you hit a belief that doesn't drill any further. That terminal belief is the core.
In a session it's a conversation. In code it's a two-level map: surface-thought keywords → intermediate belief, then intermediate-belief keywords → core belief. Drill until stable.
This is a fundamentally different algorithm from a flat classifier. A distortion detector asks "which category does this thought belong to?" (one hop). The downward arrow asks "what does this thought imply, and what does that imply?" (a chain). It's semantic drilling, not labeling.
Why not an LLM?
I considered it. Here's why I didn't:
- The belief space is small and known. Decades of CBT research have catalogued the ~13 core beliefs that drive most low self-esteem: I'm worthless, I'm unlovable, I'm incompetent, I'm a failure, I'm not good enough, I'm defective, I'm unimportant, I'm a burden, I'm stupid, I'm boring, I'm ugly, I'm weak. That's the entire output space. An LLM generating one of 13 labels is a sledgehammer on a thumbtack.
- The drill must be deterministic. A therapist running the downward arrow gets the same core belief from the same chain every time — that's what makes it a technique. An LLM's chain is stochastic. Run it twice on "I made a typo" and you might get "I'm worthless" once and "I'm a fraud" the next. For a tool meant to surface the belief someone acts on, nondeterminism is a bug, not a feature.
- Privacy is the constraint, not a nice-to-have. People type "I'm a burden to my family" into this. That sentence sent to an API is a data breach dressed as a feature. Client-side keyword matching means the thought never leaves the browser.
- Explainability is the therapy. When the tool shows the full chain — surface → intermediate → core, with the matched keywords at each hop — the user sees their own logic exposed. That exposure is the intervention. An LLM that returns "Core belief: worthlessness" with no chain is a magic 8-ball.
For a known output space, a deterministic drill, and a privacy-first context, a keyword mapper isn't a downgrade from an LLM — it's the correct architecture.
The data structure
Two maps. The first links surface-thought keywords to intermediate beliefs; the second links intermediate-belief keywords to the terminal core beliefs:
// Level 1: surface thought → intermediate belief
const SURFACE_TO_INTERMEDIATE = [
{ patterns: ["typo", "mistake", "error", "messed up", "forgot"],
belief: "I'm incompetent / not good enough" },
{ patterns: ["rejected", "left me", "didn't reply", "unmatched"],
belief: "I'm unlovable" },
{ patterns: ["burden", "too much", "they'd be better off"],
belief: "I'm a burden" },
{ patterns: ["boring", "nothing to say", "awkward"],
belief: "I'm boring" },
{ patterns: ["ugly", "fat", "unattractive", "looks bad"],
belief: "I'm ugly / defective" },
// ... 8 more
];
// Level 2: intermediate belief → core belief (the terminal nodes)
const INTERMEDIATE_TO_CORE = {
"I'm incompetent / not good enough": "I'm worthless",
"I'm a failure": "I'm worthless",
"I'm unlovable": "I'm unlovable",
"I'm a burden": "I'm a burden",
"I'm boring": "I'm unlovable",
"I'm ugly / defective": "I'm defective",
"I'm stupid": "I'm incompetent",
// ... 6 more mappings
};
// The 13 terminal core beliefs (the entire output space)
const CORE_BELIEFS = [
"I'm worthless", "I'm unlovable", "I'm incompetent", "I'm a failure",
"I'm not good enough", "I'm defective", "I'm unimportant", "I'm a burden",
"I'm stupid", "I'm boring", "I'm ugly", "I'm weak", "other"
];
That's the "model." Two lookup tables totaling ~25 entries. No embeddings, no fine-tuning, no prompt engineering.
The drill algorithm
Here's the complete detector. The drill() function chains the two hops; matchLevel() does the keyword matching at each level:
function matchLevel(text, table) {
text = text.trim().toLowerCase();
let hits = [];
for (const entry of table) {
let matched = entry.patterns.filter(p => text.includes(p));
if (matched.length > 0) hits.push({ belief: entry.belief, matched });
}
return hits;
}
function drill(surfaceThought) {
// Hop 1: surface → intermediate
let intermediate = matchLevel(surfaceThought, SURFACE_TO_INTERMEDIATE);
if (intermediate.length === 0) return null;
// Hop 2: intermediate → core (look up each, dedupe)
let cores = [];
for (const i of intermediate) {
let core = INTERMEDIATE_TO_CORE[i.belief] || i.belief; // stable if already core
if (!cores.find(c => c.core === core)) {
cores.push({ core, via: i.belief, matched: i.matched });
}
}
return { surface: surfaceThought, intermediate, cores };
}
That's the engine. matchLevel() does substring matching at one level; drill() calls it twice and stitches the chain. ~20 lines of logic.
Why two levels and not three? I tried three. The third hop almost always collapsed back to the same 13 core beliefs — because core beliefs are terminal by definition (they don't imply anything deeper about the self). Two hops captures the real structure: a wide layer of surface thoughts, a narrower layer of intermediate beliefs, and 13 roots.
The corrective half: the positive data log
Detecting the core belief is only half the tool. The active ingredient in CBT for low self-esteem is the positive data log — daily evidence that contradicts the core belief. "I'm worthless" dissolves not by argument but by accumulated counter-evidence.
So the tool has a second data structure — a log of entries against each core belief:
function logCounterEvidence(coreBelief, evidence) {
let log = JSON.parse(localStorage.getItem("positive_data_log") || "[]");
log.push({
date: new Date().toISOString(),
belief: coreBelief,
evidence: evidence, // "Finished the deploy script; it worked first try"
against: true
});
localStorage.setItem("positive_data_log", JSON.stringify(log));
return trendFor(coreBelief); // how many entries, belief weakening?
}
function trendFor(coreBelief) {
let log = JSON.parse(localStorage.getItem("positive_data_log") || "[]");
let entries = log.filter(e => e.belief === coreBelief && e.against);
return {
count: entries.length,
recent: entries.slice(-5).map(e => e.evidence),
verdict: entries.length >= 10 ? "belief weakening — keep logging"
: entries.length >= 3 ? "evidence accumulating"
: "log more counter-evidence"
};
}
The belief weakens by count, not by sentiment score. Ten concrete entries of "I did X competently" outweighs any model's confidence that the belief is "0.3 active." Counting is the signal. That's the whole insight — and it needs no ML.
Confidence and the "other" bucket
Not every thought maps cleanly. "I feel weird about the meeting" matches nothing. The tool returns null from drill() and the UI prompts: "What would it mean about you if that were true?" — handing the downward arrow back to the user. The ~15% of inputs that don't match are the most therapeutically interesting, because they're where the user's vocabulary doesn't overlap the clinical one. An LLM would confabulate a belief; the keyword mapper honestly says "I don't see a chain — you tell me."
That honesty is a feature. A tool that always returns an answer teaches the user to trust the tool. A tool that sometimes says "your move" teaches the user to do the technique themselves — which is the goal of CBT.
What I'd do differently at scale
The two-level map breaks down when:
- The surface vocabulary is huge or multilingual. 25 surface entries covers English self-esteem thoughts well. Add Spanish, Japanese, slang, and you'd want embeddings to cluster surface phrases before mapping — but still map to the same 13 cores. The output space stays small; only the input matching gets smarter.
- You want belief strength, not just identity. Right now the tool detects which belief and counts counter-evidence. Scoring how strongly someone holds it (0-100) is a separate input the user rates — not something to infer from text. Inferring conviction from word choice is where LLMs earn their keep, and where I'd add one as a second stage, never the first.
- The belief space grows. 13 core beliefs covers low self-esteem. Add schema therapy (18 early maladaptive schemas) or personality-disorder beliefs and the INTERMEDIATE_TO_CORE map gets crowded. Then disambiguation logic — not a bigger model — is the fix.
For 13 terminal beliefs in a privacy-first self-esteem tool, none of these apply. The smallest architecture that exposes the full chain is the right one.
The full architecture
Every tool in the CBT toolkit follows the same shape:
┌──────────────────────────────────────────────┐
│ Single HTML file (no build step) │
│ ├── <style> (CSS, no framework) │
│ ├── <script> (vanilla JS) │
│ │ ├── SURFACE_TO_INTERMEDIATE[] (level 1) │
│ │ ├── INTERMEDIATE_TO_CORE{} (level 2) │
│ │ ├── drill() (the downward arrow) │
│ │ ├── logCounterEvidence() (the fix) │
│ │ ├── render() (DOM updates) │
│ │ └── save() (localStorage) │
│ └── JSON-LD (schema.org structured) │
└──────────────────────────────────────────────┘
- No npm, no bundler, no framework. Each page is self-contained and opens directly in a browser.
- localStorage for the positive data log. Counter-evidence accumulates across sessions, on-device, no server.
- The chain is rendered, not just the result. Surface → intermediate → core is shown with matched keywords at each hop — the exposure is the intervention.
-
Schema.org JSON-LD so search engines parse the tool as a
WebApplication.
The results
The detector handles chains like:
Input: "I forgot to invite him. He probably thinks I don't care.
I always ruin things."
Drill:
Hop 1 (surface→intermediate):
"forgot" → "I'm incompetent / not good enough"
"always ruin" → "I'm a failure"
Hop 2 (intermediate→core):
"I'm incompetent / not good enough" → "I'm worthless"
"I'm a failure" → "I'm worthless"
Core belief: "I'm worthless" (2 paths converge — high confidence)
+ Prompt: log evidence against "I'm worthless"
Two surface thoughts, two intermediate beliefs, both converging on one core. The convergence is itself a signal — when multiple paths drill to the same root, that root is load-bearing. I display the convergence count as confidence, which is more honest than any probability a model would emit.
Total code: ~120 lines. Total dependencies: 0. Total API calls: 0. Total thoughts sent to a server: 0.
Takeaways for builders
- A known, small output space changes everything. When the answer is one of 13 things, you don't need a language model — you need a lookup. The hard part is getting to the lookup, and a two-level keyword drill does that deterministically.
- Chains beat labels. A flat classifier returns "worthlessness." A drill returns how you got there. In any context where the user needs to understand the output (therapy, debugging, root-cause analysis), the chain is the product.
- Honest gaps are a feature. Returning "I don't see a chain — your turn" on unmatched input builds user skill. A system that always confabulates an answer builds dependence.
- The corrective loop is separate from detection. Detecting the belief is cheap; weakening it takes accumulated counter-evidence over time. Don't try to do both in one model call — they're different problems with different timescales.
If you want to see the full code or use the tools:
- CBT for Low Self-Esteem — Core Belief Tracker + Positive Data Log — the tool this article is about
- CBT Toolkit Hub — all 23 tools, each self-contained
- Cognitive Distortion Checker — the flat classifier companion to this drill
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 handing to an LLM that might just be a two-level lookup table in disguise?
More CBT Tools Built with Vanilla JavaScript
- How I Built a Cognitive Distortion Detector in 200 Lines of Vanilla JavaScript — flat classification via keyword-pattern matching
- How I Built a Safety Behavior Detector in 90 Lines of Vanilla JavaScript — classifying actions into CBT safety categories
All 23 tools live at CBT Toolkit — free, no signup, no backend.
Related Articles — More Vanilla JS Mental Health Tools
- How I Built a Cognitive Distortion Detector in 200 Lines of Vanilla JavaScript
- localStorage as a Database: How I Built 23 Mental Health Tools with Zero Backend
- How I Built an Analytics Dashboard in 150 Lines of Vanilla JavaScript
- How I Built a Catastrophic Thought Reframer in 15 Lines of Vanilla JavaScript
- How I Built a Safety Behavior Detector in 90 Lines of Vanilla JavaScript
- How I Built a Habituation Pattern Detector in 90 Lines of Vanilla JavaScript
- How I Built a Catastrophe Prediction Calibration Detector in 90 Lines of Vanilla JavaScript
All tools are free, open-source, privacy-first (no signup, no backend, no AI). Try them at 473185670.github.io/cbt-toolkit.
📚 More in this series
- How I Built a Cognitive Distortion Detector in 200 Lines of Vanilla JavaScript — No ML, No NLP Library, No API
- How I Built a Safety Behavior Detector in 90 Lines of Vanilla JavaScript — No AI, No ML, No Behavioral Analysis API
- How I Built a Habituation Pattern Detector in 90 Lines of Vanilla JavaScript — No AI, No ML, No Time-Series Library
- How I Built a Catastrophe Prediction Calibration Detector in 90 Lines of Vanilla JavaScript — No AI, No ML, No Statistics Library
- How I Built a Catastrophic Thought Reframer in 15 Lines of Vanilla JavaScript — No AI, No API, No LLM
- How I Built an Analytics Dashboard in 150 Lines of Vanilla JavaScript — No Chart.js, No D3, No Backend
- How I Built a localStorage Database for 23 Mental Health Tools — Zero Backend, Zero Signup, Zero Dependencies, Zero Libraries
All tools are free, run in your browser, and need no signup. Full collection: CBT Toolkit
Top comments (0)