Force Flow

Every notification in the haus used to go through three different systems. Home Assistant had its own push notifications. The Living Force had sanctum_notify. The Council Router had its escalation chain. Three notification stacks, four channels, zero coordination — which is how you end up with Yoda’s cloned voice shouting about Ring motion through the bathroom speaker at 7 AM while your phone buzzes twice about the same squirrel.
Force Flow is the fix. One service. One brain. Everything flows through it.
Architecture
Section titled “Architecture” ALL NOTIFICATION SOURCES │ ┌──────────┬───┴───┬──────────┐ HA Living Force Council Agents │ │ Router │ └──────────┴───┬───┴──────────┘ │ ┌──────▼──────┐ │ Force Flow │ │ port 4077 │ └──────┬──────┘ │ ┌────────────┼────────────┐ │ │ │ ┌─────▼─────┐ ┌───▼───┐ ┌─────▼─────┐ │ iPhone │ │ Sonos │ │ Dashboard │ │ (push) │ │ (TTS) │ │ (banner) │ └───────────┘ └───────┘ └───────────┘Every notification hits the /notify endpoint with a source, severity, title, and message. Force Flow decides where it goes based on two things: how bad is it, and what time is it.
The thing that makes this tractable is context discipline. Force Flow is the delivery brain, not the memory layer. When a council session needs message history, notes, calendar state, or local files to decide whether something is noise or a real escalation, that context comes from Jocasta MCP. Jocasta reads the archive; Force Flow decides whether the archive warrants waking a human up.
Routing Table
Section titled “Routing Table”| Severity | 08:00–22:00 | 22:00–08:00 (Quiet) |
|---|---|---|
| info | Dashboard only | Log only |
| warn | iPhone (silent) + Dashboard | Log only |
| error | iPhone (sound) + Dashboard | iPhone (silent) + Dashboard |
| critical | iPhone + Sonos + Signal + Dashboard | iPhone + Sonos (reduced) + Signal + Dashboard |
Deduplication
Section titled “Deduplication”Ring doorbells love to fire four motion events in thirty seconds. Without dedup, that is four push notifications, four Sonos announcements, and one family member who is now very awake and very angry.
Force Flow hashes each notification by source + title + message. If the same hash appears within a 120-second window, duplicates are suppressed. The first one goes through. The rest get logged with deduplicated: true and go nowhere.
Rate Limiting
Section titled “Rate Limiting”Non-critical notifications are capped at 10 per hour. When a service is flapping — restarting every 60 seconds, firing an alert each time — you get the first 10 and then silence until the hour rolls over.
Critical bypasses the rate limit. If something genuinely urgent fires 15 times, you hear about it 15 times. That is a feature, not a bug.
Screen Time
Section titled “Screen Time”The same Python process that decides whether to wake you at 3 AM about a raccoon is also the one that, at 22:00, tells the Firewalla to pause the first-floor Apple TV’s streaming services while leaving its HomeKit hub traffic alive. Force Flow co-hosts the haus’s curfew enforcement — the network-level parental controls the family interacts with every day.
This co-hosting is deliberate. Force Flow already owns the haus’s notification rhythm — quiet hours, daily digest, deduplication. Screen Time needs exactly those primitives: the wind-down warnings, the morning unblock, the parent-only override flow. One process, one config, one log, one sqlite database, one service to restart.
/notify /screen/* /pwa/* │ │ │ └───────────┼────────────┘ │ v ┌──────────▼───────────┐ │ Force Flow :4077 │ │ │ │ • notify hub │ ← above │ • screen_time.py │ ← this section └──────────┬───────────┘ │ v Firewalla :1984 bridge (MAC-pause + service-DNS)Daily-driver endpoints live under /screen/* — /screen/status, /screen/override, /screen/homework, /screen/credit. The family PWA lives at /pwa/. Full engine, tier model, schedule logic, and resilience layers are documented in Screen Time.
# Send a notificationcurl -X POST http://127.0.0.1:4077/notify \ -H "Content-Type: application/json" \ -d '{"source": "windu", "severity": "error", "title": "Motion Alert", "message": "Ring detected motion at front door"}'
# Response{"status": "sent", "channels": ["iphone", "dashboard"], "deduplicated": false, "rate_limited": false, "quiet_hours": false}
# Health checkcurl http://127.0.0.1:4077/health{"status": "ok", "quiet_hours": false, "hourly_count": 3, "max_per_hour": 10}
# Query historycurl "http://127.0.0.1:4077/history?limit=10&severity=error"Notify Payload
Section titled “Notify Payload”| Field | Type | Required | Description |
|---|---|---|---|
source | string | yes | Who is sending: ha, windu, sanctum, living-force, council |
severity | string | yes | info, warn, error, critical |
title | string | yes | Short title for the notification |
message | string | yes | Full message body |
force_channels | list | no | Override automatic routing (e.g. ["iphone", "sonos"]) |
Integration Points
Section titled “Integration Points”Home Assistant
Section titled “Home Assistant”HA automations call Force Flow via rest_command.force_flow instead of notify.notify or notify.mobile_app. This means HA never decides where a notification goes — it describes what happened and how bad it is, and Force Flow handles the rest.
# In an automation action:- service: rest_command.force_flow data: source: windu severity: error title: "Ring Motion While Away" message: "Motion detected at Front Door while armed away."Sanctum (Living Force, Watchdog)
Section titled “Sanctum (Living Force, Watchdog)”The supervision layer is the most integrated: the Rust watchdog and the heal-actions and sentinel scripts under ~/.sanctum/ all curl :4077/notify directly, so the brain gets to decide their fate. The Rust side is wired; the shell side is mid-migration.
The legacy sanctum_notify bash function in lib/notify.sh is the holdout. It still runs its own local stack — osascript, the dashboard alerts.json, Signal — and does its own quiet-hours gating, never touching 4077. It is the last direct path on the list below, and it is on the list to die.
sanctum_notify "Service Down" "Gateway is unreachable" "error"# → today: osascript + dashboard + Signal, computed in-process# → planned: POST http://127.0.0.1:4077/notify, local stack as fallbackCouncil Router
Section titled “Council Router”Council agents sending alerts to Bert route through Force Flow instead of direct Signal messages. The escalation config in escalation.json defines severity thresholds; Force Flow handles the actual delivery.
Jocasta MCP
Section titled “Jocasta MCP”Jocasta does not deliver notifications herself. She gives the council the missing record before a notification is sent: recent Messages context, calendar conflicts, Apple Notes fragments, prior files, and local system telemetry. That keeps Force Flow from becoming a bloated reasoning service. The split is deliberate:
- Jocasta answers “what do we know?”
- Force Flow answers “does Bert need to hear about this now?”
For example, a council workflow can pull the last relevant thread from Jocasta, synthesize the risk, then hand Force Flow a single high-signal warn or critical event instead of three half-informed pings.
History
Section titled “History”Every notification — sent, deduplicated, or rate-limited — is logged to SQLite at ~/.sanctum/force-flow/notifications.db. This means you can answer “what woke me up last Tuesday” with a query instead of a guess.
# Last 5 critical alertscurl "http://127.0.0.1:4077/history?limit=5&severity=critical"
# Direct SQLite querysqlite3 ~/.sanctum/force-flow/notifications.db \ "SELECT timestamp, source, title FROM notifications WHERE severity='critical' ORDER BY id DESC LIMIT 10"Service Details
Section titled “Service Details”| Property | Value |
|---|---|
| Port | 4077 |
| LaunchAgent | com.sanctum.force-flow |
| Interpreter | /opt/homebrew/bin/python3 (python@3.14) |
| Script | ~/.sanctum/force-flow/force_flow.py |
| Log | ~/.openclaw/logs/force-flow.log |
| Database | ~/.sanctum/force-flow/notifications.db |
| KeepAlive | Yes (restarts on crash) |
What It Replaced
Section titled “What It Replaced”Before Force Flow, the notification stack looked like this:
| Component | What it did | Problem |
|---|---|---|
notify.notify (HA) | Push to all devices | No dedup, no quiet hours, double-notified |
notify.mobile_app (HA) | Push to iPhone | Separate from notify.notify, often both fired |
script.security_announcement (HA) | Sonos TTS via bridge | Had its own quiet hours logic, no coordination |
sanctum_notify (bash) | macOS + dashboard + Signal | Completely separate stack, no dedup |
escalation.json (Council) | Severity → channel mapping | Config only, no enforcement |
Five systems, zero awareness of each other. Force Flow is one system that knows everything.
If Force Flow is the switchboard, Jocasta MCP is the archive room behind it: less glamorous, more dangerous, and usually the reason the right alert reaches the right person with fewer false alarms.
The migration is nearly complete. Almost every notification source in the haus routes through Force Flow:
| Source | Integration | Fallback |
|---|---|---|
| HA automations | rest_command.force_flow | None (HA native) |
| Sanctum watchdog (Rust) | curl :4077/notify | local |
| Heal-actions + sentinels | curl :4077/notify | local |
sanctum_notify() (lib/notify.sh) | direct local stack (not yet on 4077) | — |
The shell helper is the one holdout — it still notifies on its own, which is exactly why it is on the to-do list and not in the win column. Everything else, if it alerts, flows through 4077. “All of it” is the goal; “almost all of it” is the honest status, and the gap is one bash function wide.