Health calculator
The health calculator tool (ctx.HEALTH_CALCULATOR) is a built-in tool available inside any loop. It exposes deterministic, PHI-free reference math for common adult vital-sign ranges, screening risk scores, a fixed symptom red-flag list, and a country-aware emergency-routing helper.
Use it when your loop needs to classify a reading or judge urgency and you don’t want to call out to a model. Everything here is local math — no network calls, no secrets, no data access — so the same inputs always produce the same answer, and the result lands in the step.do row where a reviewer can audit it. Every result includes a source or note string citing the underlying public guideline (CDC, AHA, Framingham) so the loop can attribute the classification when it surfaces results to a user.
What this tool is for
- Classifying a vital-sign reading against an adult reference range.
- Producing a short, source-attributed note your loop can render to the user or include in a downstream message.
- Deciding urgency for a described symptom deterministically, instead of from a model’s read of the words.
- Screening risk scores from the inputs a facial scan plus a short intake already give you.
- Routing a user to the right emergency number for their country when an upstream concern is flagged.
What this tool is not for
- Not a diagnosis. Every reference range is a screening boundary — categories like
high_blood_pressure_stage_2describe the reading, not the person. Loops should pair the category with the includedurgency/notestring when they show it to a user. - Not for children. The ranges encoded here are adult reference ranges from the CDC and AHA. Pediatric ranges are different and not in scope for this tool.
- Not clinical risk.
healthRisksis educational screening. Only the CVD figure and the vascular age derived from it come from a published model; the rest are simple additive screens. Clinic-grade risk needs a lipid panel and a proper cuff. - Not a differential.
symptomRedFlagscan say “this one shouldn’t wait”. It can never say what the cause is. - Not a substitute for clinician review. The emergency-routing helper points to public emergency numbers and crisis lines; it does not page a clinician.
Tool API
ctx.HEALTH_CALCULATOR exposes nine methods. All of them must be called inside a step.do callback — ctx only exists there. See Step context for the surrounding API.
type BmiResult = {
bmi: number;
category: "underweight" | "healthy_weight" | "overweight" | "obesity";
source: string;
};
type BloodPressureResult = {
category:
| "hypertensive_crisis_range"
| "high_blood_pressure_stage_2"
| "high_blood_pressure_stage_1"
| "elevated"
| "normal";
urgency: string;
source: string;
};
type RestingHeartRateResult = {
category: "very_low" | "low" | "typical_resting_range" | "high" | "very_high";
note: string;
};
type BreathingRateResult = {
category: "very_low" | "typical_adult_resting_range" | "mildly_high" | "high";
note: string;
};
type Spo2Result = {
category: "typical_range" | "lower_than_expected" | "urgent_range";
note: string;
};
type EmergencyConcern =
| "medical_emergency"
| "self_harm"
| "personal_safety"
| "mental_health_support"
| "substance_use_support";
type EmergencyRoutingResult = {
country: string;
emergency: string;
mentalHealth?: string;
personalSafety?: string;
nonEmergency?: string;
source: string;
};
type AlcoholUnitsResult = {
units: number;
formula: string;
note: string;
};
type HealthRisksResult = {
/** 10-year general CVD risk, or null outside the model's 30–74 age range. */
tenYearCvdRiskPercent: number | null;
vascularAgeYears: number | null;
hypertensionRiskPercent: number | null;
diabetesRiskPercent: number | null;
fattyLiverRisk: "low" | "moderate" | "high";
basedOn: {
age: number;
gender: "male" | "female";
bmi: number;
bloodPressure: string;
smoker: boolean;
diabetes: boolean;
treatedForHypertension: boolean;
};
note: string;
};
type SymptomRedFlagsResult = {
symptom: string;
redFlags: { id: string; reason: string }[];
frame: "self_care" | "see_gp_within_days" | "urgent";
/** Details that would sharpen the frame but weren't supplied. */
stillToAsk: string[];
guidance: string;
source: string;
};
type HealthCalculatorTool = {
bmi(input: { heightCm: number; weightKg: number }): Promise<BmiResult>;
bloodPressure(input: {
systolic: number;
diastolic: number;
}): Promise<BloodPressureResult>;
restingHeartRate(input: { bpm: number }): Promise<RestingHeartRateResult>;
breathingRate(input: {
breathsPerMinute: number;
}): Promise<BreathingRateResult>;
oxygenSaturation(input: { percent: number }): Promise<Spo2Result>;
alcoholUnits(input: {
volumeMl: number;
abvPercent: number;
}): Promise<AlcoholUnitsResult>;
healthRisks(input: {
age: number;
gender: "male" | "female";
bmi: number;
systolic: number;
diastolic: number;
isSmoker?: boolean;
hasDiabetes?: boolean;
isTreatedForHypertension?: boolean;
}): Promise<HealthRisksResult>;
symptomRedFlags(input: {
symptom: string;
memberWords: string;
durationDays?: number;
severity?: "mild" | "moderate" | "severe";
whatChangesIt?: string;
}): Promise<SymptomRedFlagsResult>;
emergencyRouting(input: {
country?: string;
concern: EmergencyConcern;
}): Promise<EmergencyRoutingResult>;
};| Method | Description |
|---|---|
bmi | Round-to-1dp BMI with adult CDC category. Input in centimetres and kilograms. |
bloodPressure | Classify a systolic/diastolic pair against the AHA categories. Returns a category and an urgency string suitable to show users. |
restingHeartRate | Classify a resting bpm reading. Returns a category and a note covering context (athletes, symptoms, etc.). |
breathingRate | Classify resting breaths per minute against the adult range. |
oxygenSaturation | Classify an SpO2 percentage against typical, lower-than-expected, and urgent ranges. |
alcoholUnits | Convert one drink’s volume and ABV to UK alcohol units. Unit definitions vary by country — surface it as a UK-style conversion. |
healthRisks | Screening risk scores from age, sex, BMI, and blood pressure. See Screening risk scores. |
symptomRedFlags | Check a described symptom against a fixed red-flag list. See Symptom red flags. |
emergencyRouting | Return country-appropriate emergency, mental health, and personal safety numbers for the given concern. |
Screening risk scores
healthRisks runs five screens off the inputs a facial scan plus a short intake already give you — no bloods required.
| Field | Model | Notes |
|---|---|---|
tenYearCvdRiskPercent | Framingham 2008 general CVD, BMI variant (D’Agostino et al.) | null outside the model’s validated 30–74 age range |
vascularAgeYears | Same model, solved for age at optimal risk factors | The age at which an otherwise-optimal person carries this risk |
hypertensionRiskPercent | Additive screen anchored on the AHA blood-pressure stages | Not a validated instrument |
diabetesRiskPercent | Additive screen over age, BMI, and smoking | Returns 100 when hasDiabetes is true |
fattyLiverRisk | BMI banded by sex | Coarse; low / moderate / high |
Only the first two are published science. The other three are simple additive screens over the same inputs — useful for “is this worth a conversation?”, not for anything that looks like a clinical number. Every result carries a note saying so, and basedOn echoes the inputs so a loop can show its working.
The BMI, systolic, and diastolic values usually come from a completed scan; isSmoker, hasDiabetes, and isTreatedForHypertension come from intake and default to false when you don’t know.
Symptom red flags
symptomRedFlags checks a described physical symptom against a fixed list of the handful of presentations that shouldn’t wait, and returns one of three frames:
| Frame | When | What the loop should do |
|---|---|---|
urgent | A red-flag rule matched | Route to emergency care, and say plainly why this one can’t wait |
see_gp_within_days | No flag, but severity is severe or it’s run 14+ days | Name the symptom, give one thing to do now, advise a GP within days |
self_care | No flag, nothing long-running or severe | Name the symptom, one self-care step, say what would change the advice |
A rule fires only when all of its patterns match the combined symptom and memberWords text. That’s what makes the compound rules meaningful: “chest pain” alone isn’t a flag, chest pain plus breathlessness is. Pass the member’s actual words in memberWords — the rules match against their phrasing, so a paraphrase can lose the signal.
stillToAsk lists the details (durationDays, severity, whatChangesIt) you didn’t supply, so a conversational loop knows what to ask next.
Call this before emergencyRouting for physical symptoms. It only returns urgent when a rule actually matched, which keeps escalation deterministic and auditable instead of resting on a model’s judgement of the same sentence.
Example: blood-pressure triage
import { Workflow } from "./loop";
export class HealthLoop extends Workflow {
async run(
event: HealthEvent<{ systolic: number; diastolic: number }>,
step: HealthStep,
) {
const classification = await step.do("classify-bp", async ({ ctx }) =>
ctx.HEALTH_CALCULATOR.bloodPressure({
systolic: event.body.systolic,
diastolic: event.body.diastolic,
}),
);
if (classification.category === "hypertensive_crisis_range") {
const routing = await step.do("escalate", async ({ ctx }) =>
ctx.HEALTH_CALCULATOR.emergencyRouting({
country: "South Africa",
concern: "medical_emergency",
}),
);
return {
action: "escalate",
category: classification.category,
urgency: classification.urgency,
emergency: routing.emergency,
};
}
return {
action: "respond",
category: classification.category,
urgency: classification.urgency,
};
}
}Example: symptom triage before routing
import { Workflow } from "./loop";
export class TriageLoop extends Workflow {
async run(
event: HealthEvent<{ symptom: string; words: string }>,
step: HealthStep,
) {
const triage = await step.do("Check red flags", async ({ ctx }) =>
ctx.HEALTH_CALCULATOR.symptomRedFlags({
symptom: event.body.symptom,
memberWords: event.body.words,
}),
);
// Only escalate when a rule actually fired.
if (triage.frame === "urgent") {
const routing = await step.do("Route to care", async ({ ctx }) =>
ctx.HEALTH_CALCULATOR.emergencyRouting({
country: "South Africa",
concern: "medical_emergency",
}),
);
return {
action: "escalate",
why: triage.redFlags.map((flag) => flag.reason),
emergency: routing.emergency,
};
}
return {
action: triage.frame,
guidance: triage.guidance,
askNext: triage.stillToAsk[0] ?? null,
};
}
}PHI posture
The calculator does not read or write any user data. It accepts numeric inputs and short text, returns categorical labels and pre-written reference strings, and emits no audit events of its own. Nothing leaves the isolate — every method is local math, including healthRisks and symptomRedFlags. The Loop DO still records the calling step.do row — that’s the source of truth for what your loop computed.
If your loop is calling the calculator on user-supplied vitals, the vitals themselves are PHI; treat the request body and the step return value with the same care you would any other patient data. The tool boundary itself just doesn’t widen the surface.
Country support for emergency routing
emergencyRouting currently knows about South Africa, the United Kingdom, Ireland, the United Arab Emirates, and Saudi Arabia. Unknown countries fall back to a global response that points to findahelpline.com and tells the user to call their local emergency number.
If your loop serves a country not in the list and you need specific numbers, capture the routing locally inside your loop and bypass the tool — or open an issue with the local public-resource sources you want us to add.