---
title: The house notices you — presence greetings from your phone
series: Build It Yourself — briefs
brief: 05
license: CC BY 4.0
tested_on: Claude Code 2.1 (Oct 2026) · Node 25 · macOS 26 · ntfy.sh (live relay) — steps 1–3 built and run by a fresh headless session (real relay, rules on a fake clock, voice cache against a stub voice API, audio rendered to file); step 4 (iPhone) written but not run on a device
effort: one evening (2–3 h) for the pipeline; +30 min per phone for the automations
---

# The house notices you

## TL;DR
Your phone already knows when you get home, leave, and switch off your alarm. Teach it to
tell your assistant, and the house can answer: a "welcome home" on the speaker twenty
minutes after you walk in, a "good morning" after your alarm, a warm hello when you are
*both* finally home. No app to install, no open port on your router, no domain. This brief
is the pipeline (phone → relay → machine → speaker), the small state machine that decides
when to speak, and the traps — most of which are about phones that stay locked and voices
that cost money.

---

## What you get (a scene)

> *(18:42 — you park, carry the bags up, put the kettle on. Nothing happens. Good.)*
>
> *(19:02 — the speaker in the living room)*
> **Speaker:** "Welcome home. Long day? There's one to-do still open — no rush."
>
> *(19:35 — your partner walks in. Twenty minutes later:)*
> **Speaker:** "Good evening, you two. Everyone's home."
>
> *(next morning, 07:10 — ten minutes after the alarm is stopped)*
> **Speaker:** "Good morning. Woke up late today — it's all good."

The twenty-minute delay is deliberate: a phone decides you are "home" in the car park. A
greeting that fires while you are still in the lift is a greeting for nobody.

---

## Prerequisites

- The always-on machine and the `notify.sh` chat line from **brief 00**
  (`start-here-always-on-agent.md`).
- **An iPhone** with the Shortcuts app (Personal Automations: Arrive / Leave a location,
  Alarm is stopped). Android equivalents (Tasker, MacroDroid) work the same way — the
  phone only has to make one HTTP request.
- **A speaker the machine can play to**: a Cast speaker on the same Wi-Fi (`pipx install
  catt`), or simply the machine's own audio out.
- A **text-to-speech** voice: the OS voice (macOS `say`, Linux `espeak`/`piper`) is free;
  a cloud voice (ElevenLabs etc.) sounds human but is metered — see trap 5.
- A **relay** the phone can reach from anywhere and the machine can read *outbound*. We use
  a small cloud function in front of a queue; the zero-setup equivalent is a private topic
  on a push service like **ntfy** (free, no account). Either way: nothing listens on your
  home network.

---

## Architecture (the 15-line version)

```
 iPhone automation (Arrive / Leave / Alarm stopped)
        │  "Get Contents of URL"  POST  {person, event, token}
        ▼
 relay (cloud queue / ntfy topic)  ◀── machine long-polls OUTBOUND (no open port)
        │
        ▼
 listener ── token ok? ── log the event ──▶ presence.json  {person: state, since, flags}
                                                │
                       tick every 60 s ─────────┤
                                                ▼
          rules: arrive +20m still home? · both home 20m (once/day, fire window) ·
                 wake +10m (cooldown) · quiet hours · stale "home" expires after 18h
                                                │
                                   speak? ──▶ clip cache ──▶ speaker
```

1. **The phone only sends one HTTP request.** Not a chat message, not an app — the one
   Shortcuts action that runs silently while the phone is locked.
2. **The relay is outbound-only from home.** The phone posts to the internet; the machine
   pulls from the internet. Your router never forwards a port, and you never need a domain.
3. **Events change state; a tick decides speech.** The listener only records "Ana: home
   since 18:42". A separate 60-second tick reads the state and decides whether anything
   should be said *now*. This is what makes the delays and the "still home?" checks trivial.
4. **Every rule has a once-per-day or cooldown flag, stored with the state.** Restarting
   the machine must not replay a greeting.
5. **A line is rendered once and replayed.** Greetings are short and repeat; synthesise each
   line once, cache the audio by a hash of its text, and play the file.

---

## Build steps — paste each block into Claude Code, in order

### Step 1 — the relay and the listener

```text
Build the inbound half of a presence pipeline on this machine (Node 20+, no framework):
- Relay: an ntfy topic (https://ntfy.sh/<topic>). Generate the topic name as 32 random
  hex chars — on a public relay the topic name is the only thing keeping strangers out
  (anyone who can read the topic can also read a token inside the messages; see trap 10) —
  and store it with a shared TOKEN in the OS secret store, not in code. (If you prefer your own cloud function + queue, keep the same message shape.)
- Message shape the phone will POST as the body: {"person":"ana","event":"home"|"away"|
  "wake","token":"<TOKEN>"}.
- ~/agent/presence/listen.js: long-poll/stream the topic OUTBOUND
  (GET https://ntfy.sh/<topic>/json, reconnect with backoff forever). For each message:
  parse the body as JSON; reject (log one line, no detail) anything with a wrong token,
  a person or event not on your list. Valid → append ONE timestamped line to
  ~/agent/presence/events.log ("2026-10-05 18:42:07 delivered ana/home") and update
  ~/agent/presence/presence.json under a file lock:
    per person: state (home|away), since (ISO), plus flags you will need later.
  A "wake" event does not change home/away; it records wake_at.
- Restarts must neither replay nor drop events: remember the last message id you processed
  in a state file and reconnect with ?since=<that id>; on a very first start (no id yet)
  use ?since=<current unix time>. After EVERY (re)connect, poll once more from the same
  point ~3 s later (?poll=1) — a message published just before the reconnect may not be in
  the server's cache yet and not in the live stream either. Dedupe by message id and handle
  messages one at a time through a queue.
- Write rejects to stderr (one line, no detail), so events.log only ever holds deliveries.
- Add a test command: node listen.js --send ana home  (posts a correctly-shaped message to
  the topic, exactly as the phone will).
```
**Check:** start the listener, run `--send ana home` from another terminal: one line in
`events.log`, `presence.json` says home. Send with a wrong token: rejected, state unchanged.
Restart the listener: the old message is not processed a second time.

### Step 2 — the rules (a tick, not a reaction)

```text
Create ~/agent/presence/tick.js, run every 60 s (setInterval inside the listener process,
or a timer job). Read presence.json and decide, using ONLY state + clock (no network):
- ARRIVE: a person went home → after 20 min, if they are STILL home and not yet greeted for
  this home-session, say an arrive line for them. (Phones flag "arrived" in the car park.)
- BOTH HOME: everyone is home, dwell >= 20 min since the LATER arrival, the later arrival
  was less than 120 min ago (so sleeping at home does not fire it next morning), not in
  quiet hours (22:00-08:00), not already fired today → say the both-home line.
- WAKE: wake_at set → 10 min later say a good-morning line for that person; 4 h cooldown.
- LEAVE: someone left while somebody else is still home → optional short line. Never speak
  to an empty house: if presence says nobody is home (or the state is uncertain), say nothing.
- STALE: a "home" older than 18 h with no newer event is treated as "not sure" (a missed
  "away" must not keep the house greeting a ghost).
- QUIET HOURS apply to every rule except WAKE. A line that falls inside them is dropped and
  its flag consumed (an arrive at 23:00 must not fire at 08:00).
- LATE LINES: every pending line has a shelf life (arrive 120 min, wake 60 min, leave
  10 min). If the tick only gets to it later (the machine was down), consume the flag
  silently with a reason — a "welcome home" three hours late is worse than none.
- ONE LINE PER MOMENT: if both-home and an arrive would fire on the same tick, say only the
  both-home line.
Every decision that speaks sets its flag BEFORE playing (once-per-day date, greeted-this-
session, wake cooldown) so a crash mid-play cannot repeat it. Write the reason for the last
decision ("both home 14m < 20m", "quiet hours", "fired today") into presence.json →
last_reason, so "why didn't it say anything?" has an answer.
Add --at "2026-10-05T19:05" to run one tick against a fake clock, and --dry to print what
would be said instead of saying it.
```
**Check:** with `--dry` and `--at`, walk a day: `ana/home` at 18:42 → tick at 18:55 says
nothing ("arrive pending 13m"), at 19:03 the arrive line, at 19:04 nothing (already
greeted). Second person home at 19:35 → both-home at 19:56, and not again at 21:00. Ticks
at 23:00 say nothing (quiet hours). `last_reason` explains every silence.

### Step 3 — the voice (rendered once, replayed forever)

```text
Create ~/agent/presence/say.sh "<text>":
- Look up a cached clip: ~/agent/presence/clips/<first 12 hex of sha256(voice|text)>.mp3.
  Hit → play it. Miss → synthesise with the cloud voice if configured; if the API returns
  ANYTHING other than HTTP 200 (quota errors are not always 429 — ours came back as 401),
  fall back to the free OS voice and speak live. A 200 is not proof either: check the body
  really is audio (not a small JSON error) before trusting it. Cache ONLY a file produced
  by the primary voice that passed both checks; never cache the fallback, or the robot
  voice becomes permanent. Pass the API key to curl on stdin (curl -K -), not as an
  argument. The OS voice writes formats a Cast speaker will not play (macOS say → .aiff):
  convert it to mp3 (ffmpeg) before casting.
- Play to the speaker: catt -d <speaker> cast <file> (resolve the speaker by name with a
  remembered last-known IP, as any LAN device's address can change), or to local audio.
- Greeting lines live in a JSON file, one SMALL fixed set per event per person (start each
  line with a real greeting + the person's name; warm first, at most one cheeky line).
  Add `say.sh --warm` that pre-renders every line in that file once.
```
**Check:** `say.sh --warm` renders each line once; a second `--warm` makes zero API calls.
Break the API key: the greeting still plays (OS voice) and nothing new lands in `clips/`.

### Step 4 — the phone (10 minutes per automation, no code)

```text
Write me step-by-step iPhone Shortcuts instructions (Personal Automation) for three
automations — Arrive Home, Leave Home, Alarm is stopped — each containing exactly ONE
action: "Get Contents of URL", method POST, URL https://ntfy.sh/<topic>, request body
JSON {"person":"<me>","event":"home|away|wake","token":"<TOKEN>"}.
Include the settings that make it run while the phone is locked: "Run Immediately" (not
"Ask Before Running"), "Notify When Run" off, Location access for Shortcuts set to
"Always", Background App Refresh on. Also: run each automation's action once by hand and
answer "Always Allow" to the first-run "allow this shortcut to send data to …?" prompt (an
automation waiting on that prompt on a locked phone does nothing); for "Alarm is stopped",
pick only the morning alarm; never share these Shortcuts by iCloud link (the link carries
the topic and token). Then give me a one-tap manual Shortcut that sends "home" so I can
test from the lock screen while watching events.log.
```
**Check:** lock the phone, tap the manual test Shortcut from the lock screen — a line lands
in `events.log` within seconds. Then walk out and back in for real; the log is the only
audit trail you need ("delivered ana/away", "delivered ana/home").

---

## The traps we hit (read these twice)

**1. A message-sending Shortcut does not run on a locked phone.** Our first version had the
automation send a chat message ("#home") — iOS showed a share sheet waiting for a tap, so
nothing arrived until the phone was unlocked. *Fix:* the only action is "Get Contents of
URL", which runs silently while locked; and "Ask Before Running" must be off — with it on,
the automation becomes a notification that waits for you.

**2. "Arrived" fires in the car park.** A geofence triggers before you are in the door; an
instant greeting plays to an empty room. *Fix:* delay arrive greetings ~20 minutes and
re-check the person is still home at fire time.

**3. "Both home" fired the next morning.** Two people home and asleep is technically "both
home for 20 minutes" all night. *Fix:* a fire window — the later arrival must be recent (we
use 120 minutes) — plus once per day and quiet hours.

**4. A missed "away" left the house greeting a ghost.** One failed leave event and a person
is "home" forever. *Fix:* a home state older than ~18 hours with no newer event expires to
"not sure"; "not sure" never triggers speech.

**5. Testing burned a month of voice in a day.** Every greeting was synthesised live by a
metered cloud voice; a day of testing used the entire monthly quota and then *every* voice
in the house went silent. *Fix:* a small fixed set of lines, rendered once and cached by a
hash of the text (zero cost per greeting afterwards), a free OS voice as the fallback on
any non-200, and never caching a fallback render.

**6. The quota error was not a 429.** We had a fallback for "rate limited" — but the
provider's out-of-quota reply was an HTTP 401, which our code treated as fatal, so the house
went quiet instead of robotic. *Fix:* fall back on *anything* other than 200; diagnose by
reading the provider's usage endpoint, not by guessing from the status code.

**7. A voice nobody is home to hear is pure spend.** A reminder or a "goodbye" line can
fire after the last person has left. *Fix:* speak only when presence says someone is home —
and treat "not sure" as nobody.

**8. The relay kept no record of who said what.** When a greeting did not fire, the cloud
side had no log of the person or the event, so we could not tell "the phone never sent" from
"the machine ignored it". *Fix:* the listener writes one timestamped line per delivered event.
That file answered every "did my phone fire?" question since.

**9. Events sent during a reconnect vanished.** The listener resumed from "the last message
I saw", yet a message sent while it was restarting was never delivered: the relay writes a
message to its cache a moment after accepting it, so one sent right before the reconnect was
in neither the catch-up query nor the live stream. A fresh test build of this brief found it
on its first restart. *Fix:* after every reconnect, poll once more from the same point a few
seconds later, and dedupe by message id.

**10. On a public relay, the topic name IS the password.** We put a token in the message
body as a second lock — but anyone who can read the topic can read the token too. *Fix:* treat
the topic name as the secret (long, random, never in a screenshot), or use an access-
controlled topic (ntfy accounts and self-hosting both support it) or your own cloud function
that checks the token before queueing. And keep the payload boring: who and home/away,
nothing else.

**11. Phone signals are good enough for greetings, not for schedules.** We once drove the
morning TV board from the wake-up signal alone; on the days the automation did not fire,
there was simply no board. *Fix:* a greeting may be missed; anything you depend on gets a
fallback (a fixed time, or a guard that tells you it did not happen). The iOS "Wake Up"
trigger in particular was flaky for us — "Alarm is stopped" or a time-of-day automation
was steadier.

---

## You know it works when

- [ ] From a locked phone, the test Shortcut lands an event in `events.log` within seconds.
- [ ] A wrong token or a person not on the list is rejected and changes nothing.
- [ ] Arrive → greeting ~20 minutes later, once per home-session; leave before 20 minutes →
      no greeting.
- [ ] Both home → one greeting that day, never the next morning.
- [ ] Nothing speaks in quiet hours or to an empty house.
- [ ] Restarting the listener neither replays nor loses events.
- [ ] After `--warm`, a week of greetings makes zero voice-API calls.

## Extend it

- **Context in the greeting.** Append one fact the house knows ("one to-do still open",
  "the package arrived") — keep the greeting itself cached and speak only the short tail
  live.
- **Day-aware overlays.** A weekend set of morning lines, a Friday-evening line, chosen by
  code before the cache lookup.
- **Feed other lanes.** Presence is a signal other briefs can read: reminders that fire on
  arrival (brief 02), the morning board 20 minutes after "wake" (brief 04) — each with its
  own fallback.

## Changelog

- 2026-10-05 — first public version. Tested on the versions in the header.

---

*Part of **Build It Yourself** — free briefs on building a personal AI Chief of Staff, one
capability at a time: https://chief.natalicot.com/briefs. Free, forever. · License CC BY 4.0*
