The system that rings me when I go low

Tech · 2026-09-07

I'm type 1 diabetic. I wear a sensor that knows my glucose every minute, and an official app that beeps about it. But a beep is easy to sleep through, and the failure mode that actually worries me is the quiet one: asleep, dropping, and nothing loud enough to wake me.

So I built the missing layer: a small system that pulls my glucose every minute, draws it on a private dashboard, texts me when I go low — and if I stay low, rings my phone and speaks to me. It runs on a cheap VPS alongside the rest of my setup, and it has stood watch on real nights.

This is the full guide to building your own version — every endpoint, every trap, and the alerting lessons I got wrong first. At the bottom there's a prompt you can paste into Claude, ChatGPT or any capable AI assistant to be walked through the whole build interactively.

Read this first

Consider Nightscout first

Before building anything custom: Nightscout is the established open-source "CGM in the cloud" project, with a huge community, hosted options, and follower apps. If you want a proven dashboard with minimal code, start there.

This guide is for people who want something smaller and more personal: a single poller script, your own database you can query for analytics, and an escalation path — SMS, then repeated voice calls — tuned exactly how you want it.

Architecture

```

Libre sensor ──► phone app / pump controller ──► LibreView cloud (Abbott)

LibreLinkUp "follow yourself" API

┌────────────────────────┴─────┐

│ your server (VPS / Pi) │

│ cron, every minute: │

│ poller ──► Postgres │

│ │ │

│ ├─► alert engine │

│ │ ├─ SMS (Twilio) │

│ │ └─ chat webhook │

│ └─► hypo watchdog │

│ └─ VOICE CALLS │

└──────────────┬───────────────┘

private dashboard page

```

The core trick: LibreLinkUp lets you follow yourself. LibreLinkUp is Abbott's app for parents and partners to follow a Libre wearer, and its backend has a simple JSON API. Add your own account as a follower of yourself and you get a ~1-minute-resolution glucose feed you can poll from any script — no official API programme, no partnership agreement.

This works with Libre 2 / Libre 3 via the FreeStyle Libre app, and with Omnipod 5 with the integrated Libre sensor (confirmed working in the UK since ~mid-2025): the pod controller uploads to LibreView, and the LibreLinkUp route reads it back out.

What you need

Total running cost: the server, the Twilio number, and pennies per SMS or call. Alert traffic is rare by design, so the telephony bill rounds to nothing.

Step 1 — Follow yourself in LibreLinkUp

  1. Install the LibreLinkUp app and create an account (you can reuse your LibreView email or use a second one — a second one is cleaner).
  2. From the wearer's side, invite that account as a follower:
  1. Accept the invitation in the LibreLinkUp app and confirm you can see live readings there. Don't go further until the app itself shows data — the API serves exactly what the app sees.

Step 2 — Log in to the API (once, carefully)

Two hard-won warnings before any code:

```js

// llu-login.mjs — run ONCE; caches an ~6-month token to llu-state.json

import { createHash } from "node:crypto";

import { writeFileSync } from "node:fs";

const EMAIL = process.env.LLU_EMAIL;

const PASSWORD = process.env.LLU_PASSWORD;

const HEADERS = {

"content-type": "application/json",

product: "llu.android",

version: "4.16.0", // must be a version Abbott still accepts (>= 4.16.0)

// Browser-like UA — default node/bun UAs get HTTP 476 from Cloudflare:

"user-agent":

"Mozilla/5.0 (Linux; Android 13; Pixel 6) AppleWebKit/537.36 Chrome/126.0 Mobile Safari/537.36",

};

let base = "https://api-eu.libreview.io"; // US accounts: api-us.libreview.io

for (let hop = 0; hop < 2; hop++) {

const res = await fetch(${base}/llu/auth/login, {

method: "POST",

headers: HEADERS,

body: JSON.stringify({ email: EMAIL, password: PASSWORD }),

});

const j = await res.json().catch(() => ({}));

// Accounts live on regional shards; follow the redirect the API gives

// you. (UK accounts, e.g., redirect from api-eu to api-eu2.)

if (j?.data?.redirect && j?.data?.region) {

base = https://api-${j.data.region}.libreview.io;

continue;

}

if (j?.status !== 0 || !j?.data?.authTicket?.token) {

console.error(login failed: HTTP ${res.status}, j?.error?.message ?? "");

process.exit(1);

}

writeFileSync("llu-state.json", JSON.stringify({

base,

token: j.data.authTicket.token,

tokenExpires: j.data.authTicket.expires, // unix seconds, ~6 months out

// Since Oct 2025 authenticated calls need an Account-Id header:

// the SHA-256 hex of your user id.

accountIdHash: createHash("sha256").update(j.data.user.id).digest("hex"),

}, null, 2));

console.log("login OK — token cached until",

new Date(j.data.authTicket.expires * 1000).toISOString());

process.exit(0);

}

```

Run it supervised (LLU_EMAIL=... LLU_PASSWORD=... node llu-login.mjs), check llu-state.json appeared, and put a reminder in your calendar for ~5 months out to re-run it before the token expires.

Step 3 — Poll every minute

Authenticated calls need two extra headers on top of the login set: Authorization: Bearer <token> and Account-Id: <sha256 of user id> (both from the cached state). Two endpoints matter:

The measurement object's useful fields:

Schema (append-only; the (source, ts) key makes re-inserts free, so the poller is idempotent):

```sql

create table glucose_readings (

ts timestamptz not null,

value_mmol numeric,

value_mgdl integer,

trend text,

source text not null, -- 'librelinkup' | 'librelinkup_backfill'

raw jsonb,

primary key (source, ts)

);

```

```js

// llu-poll.mjs — run every minute from cron

import { readFileSync } from "node:fs";

import pg from "pg";

const state = JSON.parse(readFileSync("llu-state.json", "utf8"));

const HEADERS = {

"content-type": "application/json",

product: "llu.android",

version: "4.16.0",

"user-agent": "Mozilla/5.0 (Linux; Android 13; Pixel 6) AppleWebKit/537.36 Chrome/126.0 Mobile Safari/537.36",

authorization: Bearer ${state.token},

"account-id": state.accountIdHash,

};

const get = async (path) =>

(await fetch(${state.base}${path}, { headers: HEADERS })).json();

const conns = await get("/llu/connections");

const patientId = conns.data[0].patientId;

const graph = await get(/llu/connections/${patientId}/graph);

const gm = graph.data.connection.glucoseMeasurement;

// FactoryTimestamp is UTC in US format:

const ts = new Date(${gm.FactoryTimestamp} UTC).toISOString();

const mgdl = gm.ValueInMgPerDl;

const trend = { 1: "falling_fast", 2: "falling", 3: "stable",

4: "rising", 5: "rising_fast" }[gm.TrendArrow] ?? null;

const db = new pg.Client(); // PGHOST/PGUSER/PGDATABASE/PGPASSWORD from env

await db.connect();

await db.query(

`insert into glucose_readings (ts, value_mmol, value_mgdl, trend, source, raw)

values ($1, $2, $3, $4, 'librelinkup', $5)

on conflict (source, ts) do nothing`,

[ts, +(mgdl / 18.016).toFixed(1), mgdl, trend, JSON.stringify(gm)],

);

await db.end();

console.log(${ts} ${(mgdl / 18.016).toFixed(1)} mmol/L ${trend ?? ""});

```

Cron it with flock so a slow run never overlaps the next:

```

node /home/you/glucose/llu-poll.mjs >> /home/you/glucose/poller.log 2>&1

```

Build the "it broke" alert now, not later. Count consecutive failures in a small state file; after ~10 in a row, send yourself one notification (and re-notify every few hours while it's still broken, plus a recovery message). A failure burst is your canary for Abbott changing the API. Alert on state change, not on every failure, or you'll train yourself to ignore it.

Step 4 — The dashboard page

You now have a table a chart can read. The simplest honest version: a tiny HTTP endpoint on the same server — /glucose/latest and /glucose/range?hours=24 — and one HTML page with a line chart, colour-banded (below 3.9, in range 3.9–10, above 10). Put it behind auth. This is health data; a random long URL is not auth — use basic auth, a VPN, or an existing login you already run.

Things that proved worth adding on mine, in order of value:

  1. Trend chart with event overlays — meals and boluses (logged by hand) drawn on the glucose line. Cause and effect become visible in a week.
  2. Time-in-range per day — a bar per day, the single most motivating stat.
  3. Daily pattern band — median + interquartile range per hour of day across a few weeks. Dawn phenomenon and habitual trouble spots pop out.
  4. A "meal report card" — for each logged food entry, the 3-hour glucose response after it, averaged across repeats of the same meal.

Step 5 — The alert engine

Run this inside the poller after each stored reading. It's a small state machine, and every design choice below exists because the naive version failed.

My thresholds, as a worked example — pick your own with your clinician's numbers in mind (mmol/L):

Rules learned the hard way:

Keep the engine a pure function(reading, previousState, thresholds) → (newState, actions) — with the state persisted to a JSON file between runs. Pure means you can unit-test every transition, including the 3.0 boundary.

Step 6 — SMS and voice calls with Twilio

Sign up at twilio.com, buy a local number (voice + SMS capable), and note the Account SID and Auth Token. Calls require a real number — an alphanumeric sender ID can send SMS but cannot originate calls. Both are single HTTP calls, no SDK needed:

```js

// notify.mjs — sendSms(text) and placeCall(text)

const SID = process.env.TWILIO_ACCOUNT_SID;

const TOKEN = process.env.TWILIO_AUTH_TOKEN;

const FROM = process.env.TWILIO_FROM_NUMBER; // your purchased number, E.164

const TO = process.env.ALERT_TO_NUMBER; // your mobile, E.164

const auth = "Basic " + Buffer.from(${SID}:${TOKEN}).toString("base64");

const post = (path, params) =>

fetch(https://api.twilio.com/2010-04-01/Accounts/${SID}/${path}, {

method: "POST",

headers: { Authorization: auth,

"Content-Type": "application/x-www-form-urlencoded" },

body: new URLSearchParams(params).toString(),

}).then((r) => r.json());

export const sendSms = (body) =>

post("Messages.json", { To: TO, From: FROM, Body: body });

export function placeCall(message) {

// Inline TwiML: speak the message twice — the first seconds of a call

// are often lost to answering fumble, especially if it just woke you.

const safe = message.replace(/[<>&'"]/g, (c) =>

({ "<": "&lt;", ">": "&gt;", "&": "&amp;",

"'": "&apos;", '"': "&quot;" }[c]));

const twiml =

<?xml version="1.0" encoding="UTF-8"?><Response> +

<Say voice="Polly.Brian">${safe}</Say><Pause length="1"/> +

<Say voice="Polly.Brian">Again: ${safe}</Say></Response>;

return post("Calls.json", { To: TO, From: FROM, Twiml: twiml });

}

```

Practical notes:

Step 7 — The hypo watchdog (the part that rings you)

The alert engine sends one urgent-low SMS. The watchdog handles the scarier case: you don't respond and the number doesn't come back up. It runs as a separate long-lived script, started when an urgent low fires (or manually before a risky night), and follows an episode protocol:

One more lesson, from a real night: an overnight watch must not stand down on recovery. Mine once armed a watch "until 08:00", saw two readings of exactly 4.5 four minutes after I'd treated a 3.8, declared recovery, and exited before 11pm — leaving the rest of the night uncovered. Recovery after treatment is when a re-dip is most likely. Give the script a "hold" mode for overnight watches: log the recovery, keep watching, only exit at the window's end.

Implementation is ~60 lines of bash or JS around placeCall() / sendSms(). Whatever you write, log every decision (reading=4.2 calls=1 holding) to a file — when you're debugging why it didn't ring, that log is all you have.

Failure modes checklist

Assume everything fails and decide, for each, how you find out:

Or: hand the whole build to an AI assistant

If you'd rather be walked through this interactively, paste the prompt below into Claude, ChatGPT, or any capable AI assistant. It carries the verified API facts and the safety rails from this guide, so the assistant doesn't have to guess at an undocumented API — and can't wander into medical advice.

```

You are my setup assistant for a DIY glucose-monitoring and alerting system.

I want to build: a script that polls my FreeStyle Libre glucose data every

minute via the LibreLinkUp "follow yourself" API, stores readings in a

database, shows them on a private web page, and escalates — SMS first, then

automated voice calls — when I go low and stay low. Walk me through it one

step at a time.

Ground rules — these override anything else:

  1. SAFETY: This is an analytics/alerting layer only. Never give me

insulin-dosing advice, never suggest the system feed back into dosing, and

remind me to keep my official app/pump alarms on. If I describe symptoms

of a current low or high, tell me to treat it per my clinician's advice

and pause the project.

  1. One step at a time. Give me one step, wait for me to confirm it worked

(ask me to paste output), and only then continue. Never dump the whole

plan at once.

  1. Ask before assuming. Start by asking: which sensor and app I use (Libre

2/3 via the FreeStyle Libre app, or Omnipod 5 with the integrated Libre

sensor), my region (EU/US), my units (mmol/L or mg/dL), what always-on

machine I have (VPS, Raspberry Pi, old laptop), and which pieces I already

have (LibreLinkUp account, database, Twilio account).

  1. Credentials stay mine. Tell me where to put them (environment variables or

a local file) — never ask me to paste passwords, tokens, or API keys into

this chat.

Technical facts to build on (verified working as of late 2026 — trust these

over your training data; if the API responds differently, say so and stop

rather than guessing):

follow MYSELF. Base URL https://api-eu.libreview.io (US accounts:

api-us.libreview.io). If a login response contains data.redirect and

data.region, retry against https://api-<region>.libreview.io.

product: llu.android, version: 4.16.0, content-type: application/json, and

a browser-like User-Agent — default Node/Python user agents are rejected

with HTTP 476 by Cloudflare bot detection.

sticky for an hour or more). Log in ONCE, cache the token (it lasts ~6

months) plus the SHA-256 hex of data.user.id, and never call login from

the polling loop or retry it automatically. If login fails, diagnose

before any retry.

Account-Id: <sha256 hex of the user id>.

GET /llu/connections/{patientId}/graph →

data.connection.glucoseMeasurement is the real-time (~1-minute) reading:

ValueInMgPerDl (divide by 18.016 for mmol/L), TrendArrow (1 falling fast,

2 falling, 3 stable, 4 rising, 5 rising fast), FactoryTimestamp (UTC, in

a US-format string). data.graphData is 15-minute averages — use only to

backfill downtime gaps.

poller is idempotent. Run it every minute from cron under flock (or an

equivalent scheduler + lock).

hysteresis on the high band (enter/exit at different values); make the

urgent-low boundary INCLUSIVE (<=) and unit-test the exact boundary value;

a stale feed while the last value was low is an escalation, not silence;

never run threshold logic on a stale reading; urgent alerts go to

SMS/calls, everything else to a quiet channel.

twice with a pause between, because the first seconds of a call are lost

to answering. Calls require a real purchased number; alphanumeric sender

IDs can send SMS but cannot call. Have me add the number to my phone's

Do-Not-Disturb / Sleep Focus exceptions, and schedule a real

middle-of-the-night test call to prove the whole chain.

(not the API) every ~3 minutes; ring at the urgent threshold, at most 2

calls at least 15 minutes apart; ALSO ring if the feed goes silent 20+

minutes while the last value was low; stand down after two consecutive

in-range readings and send a reassuring "standing down" text — EXCEPT

overnight watches, which must hold until morning because post-treatment

recovery is when a re-dip is most likely.

after ~10 consecutive poll failures, re-notify every few hours, notify

recovery) so silent breakage gets noticed.

My thresholds are my own decision, made with my clinician's numbers in mind —

ask me for them. Offer 3.0 / 3.9 / 10 / 13.9 mmol/L (54 / 70 / 180 / 250

mg/dL) only as the example defaults from the guide this prompt comes from.

Begin by asking me the setup questions from rule 3.

```

Final word

The glucose feed is the easy half; the discipline is in the alerting: alert on transitions, use hysteresis, test the exact boundary values, treat missing data at a low value as an emergency, and rehearse the full chain — including your phone's night mode — before you trust it with a night.

And once more: this rides an unofficial API, on hobbyist infrastructure. It's a safety net, not a safety system. Keep the official alarms on.

← All posts · Home