Owner's guide

Device signals

Your phone or your Mac can report moments you were provably awake: a charger plugged in, a keyboard touched. Each report is one row in your database, and the morning sleep reading is built from those rows together with your own journal entries. This page says what a signal is, what the sleep reading does with one, how a device sends one, how the Mac daemon in your repository sends one, and what you see when a device stops.

What a signal isA signal is one row saying a device saw you awake at a moment

A device sends one request to your backend, and the backend stores one row in a device_signals table in your database: a kind such as charger or laptop, an optional label, and a timestamp. The kind is your own vocabulary. The backend accepts any name, so a new device can report itself without a release. A signal writes nothing into your journal. The sleep reading is the only thing that reads the rows, and it reads only the timestamps, so every kind counts the same: proof you were awake at that moment.

The sleep readingThe night is the longest qualifying quiet between two marks

Every 15 minutes the backend checks whether last night can be read, for each account whose Sleep reading switch is on under Settings → Sleep, and stops for the day once a reading is posted. A mark is any moment you provably left behind: every device signal from the last two days, whatever its kind, and both ends of every journal entry, except the sleep readings themselves. An entry that ran from 8:03 PM to 8:34 PM proves you were awake at 8:34, so bedtime that evening is 8:34 and not 8:03. Writing a line in your journal is as good a proof of being awake as a charger is, so the reading works with no device feed at all.

The night is the longest gap between two consecutive marks that qualifies, never merely the first: a quiet stretch over dinner is not a night.

Length
At least 3 hours, at most 14. Gaps outside that range are passed over.
Where it starts
The gap's first mark falls at 7 PM or later, or before 5 AM. Bedtime is in the evening or the small hours.
Where it ends
The gap's last mark falls today, before 3 PM. A day whose first mark is later than that gets no entry.

The result posts once in your journal: Woke 9:00 AM · slept 12h 18m (from 8:42 PM). When the day's first mark is 11 AM or later, the waking was bounded rather than observed, and the entry reads Woke by 11:40 AM · slept under 13h 02m instead of stating hours nobody measured. A night you correct stays corrected: the detector skips any day that already has its entry. Last night's sleep, in the app guide, covers the correction itself.

The entry files under one life area, and under none until that area is set. Setting it is an operator's job today — POST /api/settings/sleep-area with the area's id — because no screen offers the choice yet. The detector stores the area rather than its name and reads the name as it writes, so renaming the area changes what tomorrow morning's entry says without touching anything here. Delete the area and the mornings go back to filing under nothing. The reading still posts. What time you woke is a fact about the night, and where the night belongs is a separate choice.

A signal's timestamp that names no time zone is read on your own clock, and one the reading cannot parse is skipped.

Sending a signalAny device that can make a web request can report

The request is a POST to /api/signal at your LifeOps address, with an Authorization header holding the word Bearer and a token, and a JSON body such as {"kind": "charger", "label": "bedside", "ts": "2026-08-09T21:42:00"}. The reply repeats the kind and the timestamp stored, so a test tells you it worked without opening the app.

kind
What happened, in your own word. Required.
label
Free words about the sender, stored beside the kind. Optional.
ts
When the thing happened, for a sender that only learns after the fact. Optional. A request without one is stamped the moment it arrives.
The token
A bearer token for your account, which decides whose database the row goes in. A wrong or missing one comes back 401 and stores nothing.

Settings mints a phone token and shows it once. The backend stores only its hash. Every token minted for your account keeps working when a new one is minted, so connecting a new device never breaks a feed on an older token.

Setting it up

Make a token for my device signals.Your Claude mints a bearer token for your account and shows it to you once. The backend stores only its hash, so nothing can print it back to you later.

The Mac daemonYour repository ships the daemon that reports your Mac

scripts/laptop_signal.py watches how long it has been since the keyboard or the trackpad was touched, and posts one signal each time that changes. Input after a quiet stretch posts the label active. Fifteen minutes of quiet posts idle. Both are kind laptop, stamped with the moment the thing happened rather than the moment the daemon noticed, so an evening's last mark is the last keystroke and not the poll a quarter of an hour later.

A Mac that sleeps posts both labels on waking. The poll loop is frozen the whole time the machine is down, so the daemon compares the wall clock instead: a gap longer than three polls means it slept, and it posts idle at the last input before the machine went down and active at the wake. Opening a lid is a person awake, so the wake is stamped at that moment. Without this a Mac that slept through the night reported nothing at either end. The first poll after the wake read a fresh keystroke, found the same state the loop froze in, and had no change to report.

A post the network refuses waits beside the log rather than being dropped. A Mac that has just woken usually has no Wi-Fi for a few seconds, and the wake mark goes out in those seconds, so a post that fails is written to ~/.lifeops/logs/laptop-signal.queue.jsonl and offered again on every later poll, before any new mark, until it lands. It goes out stamped with the time it happened rather than the time the network came back, and the file holds 500 waiting marks before the oldest are dropped and named in the log.

A third label, signal_broken, says the daemon can no longer read the machine's idle time at all. It posts after ten failed polls, then at most once every six hours while the source stays unreadable, and writes ALERT into its own log at the same moment. A feed that has stopped answering otherwise reads exactly like an evening nobody touched the machine, which is the failure this label exists to make visible.

The daemon reads your backend address and a bearer token from ~/.lifeops/laptop-signal.env, two KEY=value lines named CORE_SIGNAL_URL and CORE_SIGNAL_TOKEN, so the token stays out of your repository and out of the launchd file. Every post it makes, and every one that fails, is one line in ~/.lifeops/logs/laptop-signal.log.

Installing it takes two lines in a terminal at the repository, once that env file exists. The template in scripts/com.lifeops.laptop-signal.plist has placeholders where the paths go, and the first line writes this machine's own paths over them.

Write the launchd file
mkdir -p ~/.lifeops/logs && sed -e "s|__REPO__|$PWD|" -e "s|__HOME__|$HOME|" scripts/com.lifeops.laptop-signal.plist > ~/Library/LaunchAgents/com.lifeops.laptop-signal.plist
Start it
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.lifeops.laptop-signal.plist
Stop it again
launchctl bootout gui/$(id -u)/com.lifeops.laptop-signal

When a morning posts loose, that log answers first. A line for every post means the feed is arriving and the night was genuinely quiet. Silence since some hour yesterday means the daemon stopped, and an ALERT line means it is running while the machine's idle time has gone unreadable. The newest laptop rows in your database say the same thing from the other end, and the jobs endpoint says whether the sleep job itself has been running.

When a device goes quietA stopped device shows up in the readings, not in an alert

The backend runs no check on how recently each device has reported, so a dead automation raises nothing. What changes is the reading: with your journal lines as the only marks, mornings post loose (slept under) or stop posting. The jobs endpoint shows when the sleep job itself last ran, so a healthy job with drifting readings points at a device rather than at the backend.

Checking it

My sleep reading has been loose all week.Your Claude reads the newest signal rows off the backend, names the kind that stopped arriving, and rebuilds that device's automation with you.