DEV Community

473185670
473185670

Posted on

How I Built a Safety Behavior Detector in 90 Lines of Vanilla JavaScript — No AI, No ML, No Behavioral Analysis API

Safety behaviors are the hidden engine of anxiety disorders. You check your pulse. You Google your symptoms. You avoid the elevator. You ask for reassurance. Each one feels like it's protecting you — but it's actually maintaining the anxiety.

CBT identifies 6 categories of safety behaviors and 3 categories of adaptive responses. I built a detector that classifies any logged behavior into the correct category, calculates your safety behavior rate, and generates a targeted response-prevention plan — in 90 lines of vanilla JavaScript.

No AI. No ML. No behavioral analysis API. No NLP library. Just keyword-pattern matching against a known, finite output space defined by decades of clinical research.

The clinical problem

Safety behaviors are actions you take to prevent a feared outcome or reduce anxiety in the moment. The problem: they work too well. Each time you check and find nothing wrong, your brain learns "the check was necessary — I would have been in danger without it." The check reinforces the threat belief.

This is the transdiagnostic maintaining factor across all anxiety disorders:

  • Health anxiety: body checking, Googling symptoms, seeking reassurance
  • Social anxiety: rehearsing conversations, avoiding eye contact, leaving early
  • Panic: carrying "safety" objects, sitting near exits, avoiding exercise
  • OCD: checking locks, counting, arranging, mental rituals
  • GAD: over-preparing, list-making, seeking certainty

The intervention is response prevention: systematically reducing safety behaviors. But you can't reduce what you can't identify. That's what the detector does.

Why not ML?

The output space is 9 categories — 6 safety behavior types and 3 adaptive response types. These come from CBT research (Salkovskis 1991, Abramowitz 2008). They don't change. There's no new "safety behavior type 10" that a model could discover.

ML would:

  • Require labeled training data (thousands of behavior → category pairs)
  • Be nondeterministic (same behavior → different classification on different runs)
  • Cost money per inference
  • Send user behavioral data to a server (privacy concern for mental health)
  • Be a black box (can't explain why a behavior was classified as "checking")

Rule-based keyword matching:

  • Requires zero training data (the categories are known)
  • Is deterministic (same behavior → same category, every time)
  • Costs nothing
  • Runs client-side (behaviors never leave the browser)
  • Is fully explainable (the matched keyword IS the explanation)

For a constrained output space with well-defined categories, rule-based isn't a compromise — it's the correct approach. ML is for when you don't know the categories. Here, we do.

The algorithm

Step 1: Define the behavior categories

var SAFETY = [
  { id: 'checking',       label: 'Checking',         keywords: ['check','inspect','examine','scan','monitor','verify','look at','test'] },
  { id: 'reassurance',    label: 'Reassurance-seeking', keywords: ['reassur','ask','confirm','google','search','look up','tell me','is it'] },
  { id: 'avoidance',      label: 'Avoidance',         keywords: ['avoid','skip','not go','cancel','put off','procrastinate'] },
  { id: 'escape',         label: 'Escape',            keywords: ['escape','flee','leave','run','exit','get out','had to go'] },
  { id: 'overprepare',    label: 'Over-preparing',    keywords: ['prepare','plan','organize','arrange','control','perfect','make sure'] },
  { id: 'hypervigilance', label: 'Hypervigilance',    keywords: ['watch','alert','vigilant','on guard','ready','braced'] }
];

var ADAPTIVE = [
  { id: 'approach',       label: 'Approaching',       keywords: ['approach','enter','go','attend','stay','face','confront'] },
  { id: 'tolerate',       label: 'Tolerating uncertainty', keywords: ['tolerate','accept','allow','sit with','not check','resist','wait'] },
  { id: 'cbt_skill',      label: 'CBT skill use',      keywords: ['reframe','ground','breathe','mindful','cbt','challenge','restructure'] }
];
Enter fullscreen mode Exit fullscreen mode

Each category has an id, a human-readable label, and a list of keywords. The keywords are lowercase substrings — we match with indexOf for simplicity and speed.

Step 2: Classify a single behavior

function classifyBehavior(text) {
  var t = (text || '').toLowerCase();
  if (!t) return { type: 'unknown', label: 'Not recorded', isSafety: false };

  // Check safety behaviors first (priority: most specific to least)
  for (var i = 0; i < SAFETY.length; i++) {
    for (var j = 0; j < SAFETY[i].keywords.length; j++) {
      if (t.indexOf(SAFETY[i].keywords[j]) > -1) {
        return { type: SAFETY[i].id, label: SAFETY[i].label, isSafety: true, matched: SAFETY[i].keywords[j] };
      }
    }
  }
  // Then adaptive behaviors
  for (var i = 0; i < ADAPTIVE.length; i++) {
    for (var j = 0; j < ADAPTIVE[i].keywords.length; j++) {
      if (t.indexOf(ADAPTIVE[i].keywords[j]) > -1) {
        return { type: ADAPTIVE[i].id, label: ADAPTIVE[i].label, isSafety: false, matched: ADAPTIVE[i].keywords[j] };
      }
    }
  }
  return { type: 'other', label: 'Other', isSafety: false };
}
Enter fullscreen mode Exit fullscreen mode

Safety behaviors are checked first because they're the clinically important signal. If a behavior matches both "checking" and "approaching" (e.g., "I checked then went in"), we want to flag the safety behavior — it's the one to target in treatment.

The matched field is the explainability layer: you can tell the user which keyword triggered the classification. No black box.

Step 3: Analyze a full behavior log

function analyzeBehaviors(entries) {
  if (!entries || entries.length === 0) return null;

  var results = entries.map(function(e) {
    var c = classifyBehavior(e.behavior);
    return { entry: e, classification: c };
  });

  var safetyCount = results.filter(function(r) { return r.classification.isSafety; }).length;
  var safetyRate = Math.round(safetyCount / results.length * 100);

  // Find dominant safety behavior type
  var typeCounts = {};
  results.forEach(function(r) {
    if (r.classification.isSafety) {
      typeCounts[r.classification.label] = (typeCounts[r.classification.label] || 0) + 1;
    }
  });
  var dominantType = 'none', dominantCount = 0;
  for (var k in typeCounts) {
    if (typeCounts[k] > dominantCount) { dominantType = k; dominantCount = typeCounts[k]; }
  }

  return {
    total: results.length,
    safetyRate: safetyRate,
    dominantSafety: dominantType,
    dominantCount: dominantCount,
    classified: results
  };
}
Enter fullscreen mode Exit fullscreen mode

The safetyRate is the key clinical metric. Research shows that a safety behavior rate above 60% strongly predicts treatment resistance — the person is feeding the anxiety loop faster than therapy can dismantle it.

Step 4: Generate a response-prevention plan

function generatePlan(analysis) {
  if (!analysis) return 'Log your behaviors to see your response-prevention plan.';

  var rate = analysis.safetyRate;
  var dom = analysis.dominantSafety;

  if (rate >= 60) {
    return 'Your safety behavior rate is ' + rate + '% — this is maintaining your anxiety. ' +
      'Your dominant safety behavior is "' + dom + '". ' +
      'Response prevention plan: reduce "' + dom + '" by 50% this week. ' +
      'Expect a temporary anxiety spike — this is the technique working, not failing. ' +
      'The spike peaks at 2-3 days and fades by week 2.';
  }
  if (rate >= 30) {
    return 'Your safety behavior rate is ' + rate + '% — moderate. ' +
      'Target "' + dom + '" first: practice one situation where you resist the urge. ' +
      'Log what happens. The prediction ("something bad will happen") vs. reality ("nothing happened") gap is the corrective learning.';
  }
  return 'Your safety behavior rate is ' + rate + '% — low. You are approaching feared situations and tolerating uncertainty. ' +
    'Focus on maintenance: occasional exposures, continue logging, and watch for subtle new safety behaviors creeping in.';
}
Enter fullscreen mode Exit fullscreen mode

The plan is adaptive — it scales the intervention to the severity. High rate → aggressive reduction target + expectation setting (the anxiety spike is normal). Moderate → targeted single experiment. Low → maintenance.

Step 5: Trend detection

function detectTrend(entries) {
  if (entries.length < 4) return 'insufficient data';
  var chrono = entries.slice().sort(function(a, b) { return new Date(a.when) - new Date(b.when); });
  var half = Math.floor(chrono.length / 2);
  var firstRate = analyzeBehaviors(chrono.slice(0, half)).safetyRate;
  var secondRate = analyzeBehaviors(chrono.slice(half)).safetyRate;
  if (secondRate < firstRate - 10) return 'decreasing — response prevention is working';
  if (secondRate > firstRate + 10) return 'increasing — review what triggered the shift';
  return 'stable';
}
Enter fullscreen mode Exit fullscreen mode

Split-half comparison: if the second half of your logs has a meaningfully lower safety rate than the first half, the intervention is working. The 10-point threshold avoids noise from small fluctuations.

The full code

// Safety Behavior Detector — 90 lines of vanilla JavaScript
// No AI, no ML, no NLP library, no API, no backend, no dependencies

var SAFETY = [
  { id: 'checking',       label: 'Checking',            keywords: ['check','inspect','examine','scan','monitor','verify','test'] },
  { id: 'reassurance',    label: 'Reassurance-seeking',  keywords: ['reassur','ask','confirm','google','search','look up'] },
  { id: 'avoidance',      label: 'Avoidance',            keywords: ['avoid','skip','not go','cancel','put off'] },
  { id: 'escape',         label: 'Escape',               keywords: ['escape','flee','leave','run','exit','get out'] },
  { id: 'overprepare',    label: 'Over-preparing',       keywords: ['prepare','plan','organize','control','perfect'] },
  { id: 'hypervigilance', label: 'Hypervigilance',       keywords: ['watch','alert','vigilant','on guard','braced'] }
];
var ADAPTIVE = [
  { id: 'approach',  label: 'Approaching',              keywords: ['approach','enter','go','attend','stay','face'] },
  { id: 'tolerate',  label: 'Tolerating uncertainty',   keywords: ['tolerate','accept','allow','sit with','resist'] },
  { id: 'cbt_skill', label: 'CBT skill use',            keywords: ['reframe','ground','breathe','mindful','cbt'] }
];

function classifyBehavior(text) {
  var t = (text || '').toLowerCase();
  if (!t) return { type: 'unknown', label: 'Not recorded', isSafety: false };
  for (var i = 0; i < SAFETY.length; i++)
    for (var j = 0; j < SAFETY[i].keywords.length; j++)
      if (t.indexOf(SAFETY[i].keywords[j]) > -1)
        return { type: SAFETY[i].id, label: SAFETY[i].label, isSafety: true, matched: SAFETY[i].keywords[j] };
  for (var i = 0; i < ADAPTIVE.length; i++)
    for (var j = 0; j < ADAPTIVE[i].keywords.length; j++)
      if (t.indexOf(ADAPTIVE[i].keywords[j]) > -1)
        return { type: ADAPTIVE[i].id, label: ADAPTIVE[i].label, isSafety: false, matched: ADAPTIVE[i].keywords[j] };
  return { type: 'other', label: 'Other', isSafety: false };
}

function analyzeBehaviors(entries) {
  if (!entries || !entries.length) return null;
  var results = entries.map(function(e) {
    return { entry: e, classification: classifyBehavior(e.behavior) };
  });
  var safetyCount = results.filter(function(r) { return r.classification.isSafety; }).length;
  var safetyRate = Math.round(safetyCount / results.length * 100);
  var typeCounts = {};
  results.forEach(function(r) {
    if (r.classification.isSafety) typeCounts[r.classification.label] = (typeCounts[r.classification.label] || 0) + 1;
  });
  var dominantType = 'none', dominantCount = 0;
  for (var k in typeCounts) if (typeCounts[k] > dominantCount) { dominantType = k; dominantCount = typeCounts[k]; }
  return { total: results.length, safetyRate: safetyRate, dominantSafety: dominantType, dominantCount: dominantCount, classified: results };
}

function generatePlan(a) {
  if (!a) return 'Log behaviors to see your plan.';
  if (a.safetyRate >= 60) return 'Safety rate ' + a.safetyRate + '% — high. Reduce "' + a.dominantSafety + '" by 50% this week. Expect a temporary spike (peaks day 2-3, fades by week 2).';
  if (a.safetyRate >= 30) return 'Safety rate ' + a.safetyRate + '% — moderate. Target "' + a.dominantSafety + '": resist once, log what happens. The prediction vs. reality gap is the corrective learning.';
  return 'Safety rate ' + a.safetyRate + '% — low. Focus on maintenance and watch for subtle new safety behaviors.';
}

function detectTrend(entries) {
  if (entries.length < 4) return 'insufficient data';
  var chrono = entries.slice().sort(function(a, b) { return new Date(a.when) - new Date(b.when); });
  var half = Math.floor(chrono.length / 2);
  var first = analyzeBehaviors(chrono.slice(0, half)).safetyRate;
  var second = analyzeBehaviors(chrono.slice(half)).safetyRate;
  if (second < first - 10) return 'decreasing — response prevention is working';
  if (second > first + 10) return 'increasing — review triggers';
  return 'stable';
}
Enter fullscreen mode Exit fullscreen mode

How it works in practice

var entries = [
  { when: '2026-08-18T09:00', behavior: 'checked my pulse 3 times' },
  { when: '2026-08-18T14:00', behavior: 'googled "chest flutter causes"' },
  { when: '2026-08-19T08:00', behavior: 'avoided the gym' },
  { when: '2026-08-19T12:00', behavior: 'asked my wife if I looked okay' },
  { when: '2026-08-20T08:00', behavior: 'went to work despite feeling anxious' },
  { when: '2026-08-20T15:00', behavior: 'used CBT reframe technique' }
];

var analysis = analyzeBehaviors(entries);
console.log(analysis.safetyRate);           // 67
console.log(analysis.dominantSafety);        // "Checking"
console.log(generatePlan(analysis));
// "Safety rate 67% — high. Reduce "Checking" by 50% this week.
//  Expect a temporary spike (peaks day 2-3, fades by week 2)."
console.log(detectTrend(entries));           // "decreasing — response prevention is working"
Enter fullscreen mode Exit fullscreen mode

The detector correctly identifies that 4 of 6 logged behaviors are safety behaviors (67% rate), with "Checking" as the dominant type. The plan tells the user exactly what to do. The trend detector sees that the later entries include adaptive behaviors ("went to work", "used CBT reframe") → decreasing trend.

Why this design

Priority-ordered matching: Safety behaviors are checked before adaptive behaviors. If a behavior matches both (e.g., "I checked then went in"), we flag the safety behavior — it's the clinically actionable signal.

Substring matching, not exact matching: indexOf catches "checked my pulse" via the keyword "check", without needing to enumerate every possible checking behavior. This is the same pattern as the cognitive distortion detector — and it works because the behavior descriptions use natural language that reliably contains the category keyword.

Explainability via matched field: The detector returns which keyword triggered the classification. "You were classified as 'Checking' because your behavior 'checked my pulse 3 times' contains the keyword 'check'." This is the full explanation — no black box, no "the model learned a representation."

Split-half trend detection: Not a rolling average, not a regression — just compare the first half to the second half. For a self-monitoring tool where the user logs 5-20 entries, this is the right granularity. A regression would overfit to noise; a rolling average would lag too much.

The decision framework: rule-based vs. ML

Use rule-based when:

  • The output space is known and finite (9 behavior categories from CBT research)
  • The categories are well-defined with clear boundaries (checking vs. avoidance vs. escape)
  • Explainability is required (the user needs to understand why a behavior was flagged)
  • Determinism is required (same input → same output, every time)
  • Privacy is required (data stays client-side)
  • The input language is constrained (self-reported behavior descriptions, not arbitrary text)

Use ML when:

  • The output space is unknown or evolving (new behavior types could emerge)
  • Categories are fuzzy or overlapping (behavior X is 60% checking, 40% over-preparing)
  • You need to handle adversarial input (users trying to game the classifier)
  • The input is unconstrained (arbitrary text, images, audio)

Safety behavior detection is firmly in the rule-based camp. The 9 categories have been stable for 30+ years of CBT research. The boundaries are clear. Explainability is a clinical requirement. And the behaviors are self-reported in a constrained vocabulary.

Try it

The full safety behavior detector is embedded in the CBT for Health Anxiety guide and the CBT for Social Anxiety guide — both free, both run entirely in your browser, both save to localStorage. No signup, no server, no data leaves your device.

The complete toolkit has 23 free interactive CBT tools, all built with the same philosophy: vanilla JavaScript, zero dependencies, zero backend, zero data collection. CBT Toolkit Hub.


The pattern across all these tools is the same: when the clinical categories are known and finite, rule-based keyword matching is not a fallback — it's the optimal solution. It's deterministic, private, explainable, zero-cost, and runs in 90 lines of code that anyone can read and understand. That last part matters in mental health: the user should be able to read the code and verify exactly what it does with their data.

Top comments (0)