diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbd657e --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Secrets / credentials — never commit these +.env +*.env +!*.env.example + +# digest-engine per-run output (rendered artifacts, cached ingestion context) +digest-engine/output/ + +# digest-engine: the real OPNsense IDS config carries an API key/secret. Only +# IDSconf.json.example is tracked, matching the digest-engine.env.example pattern. +IDSconf.json + +# signal-cli / Telethon session state, if ever run outside their containers +*.session +*.session-journal + +# whatsapp-bridge's persisted logged-in browser session (whatsapp-web.js LocalAuth dir) +digest-engine/whatsapp-bridge/.wwebjs_auth/ +digest-engine/whatsapp-bridge/.wwebjs_cache/ + +# thin-client: everything under live-build/config/ is generated — `lb config` writes +# its own files there, and build-thin-client-iso.sh regenerates includes.chroot/ from +# configs/ + agent/ on every run (including an /etc/thinclient-agent/config.env that +# carries MQTT credentials). Only the two hand-written inputs are tracked. +hosts/thin-client/live-build/config/* +!hosts/thin-client/live-build/config/package-lists/ +!hosts/thin-client/live-build/config/hooks/ +!hosts/thin-client/live-build/config/preseed.cfg +hosts/thin-client/live-build/auto/ + +# thin-client: live-build's own build artifacts +hosts/thin-client/live-build/.build/ +hosts/thin-client/live-build/cache/ +hosts/thin-client/live-build/chroot/ +hosts/thin-client/live-build/chroot.files +hosts/thin-client/live-build/chroot.packages.* +hosts/thin-client/live-build/binary/ +hosts/thin-client/live-build/binary.* +hosts/thin-client/live-build/*.iso +hosts/thin-client/live-build/*.img +hosts/thin-client/live-build/*.log +hosts/thin-client/live-build/*.contents +hosts/thin-client/live-build/*.files +hosts/thin-client/live-build/*.packages + +# thin-client: the wayvnc password must never be committed. The image ships a +# sentinel that makes start-wayvnc refuse to run; the real value is generated on the +# booted machine (see hosts/thin-client/README.md). +wayvnc-password +*.rsa_key.pem +hosts/thin-client/**/tls_key.pem + +# thin-client: the gallery SMB credentials, same never-commit handling as the wayvnc +# password above — only gallery-credentials.example is tracked. +gallery-credentials + +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 15820a5..7f3909f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Local-first, open-source smart home: Home Assistant + Zigbee + RuView (CSI presence) + Bermuda (BLE identity) + local LLM (Ollama) + Frigate (peephole face recognition) -+ Grocy (kitchen kiosk) + Nextcloud calendar sync. ++ Grocy (kitchen kiosk) + Nextcloud calendar sync + a Sway thin-client media station ++ a quarter-daily LLM-generated digest. See [`docs/project-plan.md`](docs/project-plan.md) for the full hardware list, software stack, and phased implementation plan. @@ -17,11 +18,14 @@ hosts/ configs/ Per-service config files (mosquitto.conf, etc.) scripts/ Host setup / bootstrap scripts llm-host/ Ollama + GPU host setup (separate physical machine) + thin-client/ Sway kiosk/media-station ISO (live-build) + thinclient-agent firmware/ ruview/ RuView ESP32-S3 CSI presence node configs esphome-ble-proxy/ ESPHome configs for Bermuda BLE proxy nodes identity/ Face<->MAC<->name correlation logic (Node-RED flow export once stabilized, or a Python service) +digest-engine/ Quarter-daily LLM digest: mail/message/news/financial + ingestion, LLM synthesis, digest-canvas SDK rendering ``` ## Status @@ -37,6 +41,9 @@ identity/ Face<->MAC<->name correlation logic (Node-RED flow - [ ] LLM host (Ollama) setup script - [ ] CalDAV / Nextcloud calendar integration notes - [ ] Identity correlation flow (Node-RED) +- [x] Sway thin-client ISO (live-build) + thinclient-agent — built, not yet boot-tested on real hardware; RDP replaced by wayvnc (resolved), remaining open items (mic-enabled rooms, exact hardware target, wayvnc password provisioning) in `docs/project-plan.md` §4 +- [ ] Thin-client follow-ups in progress: fullscreen-aware now-playing widget (cover art + controls), minimal Firefox chrome + uBlock Origin/SponsorBlock, persistent audio-output selection, outbound RDP/VNC client (`rdp-vnc.json`), HA mobile-app browser remote control (text input + mouse buttons) +- [x] Quarter-daily digest engine (mail/Signal/Telegram/Discord/WhatsApp, news, financial ingestion; LLM synthesis; digest-canvas SDK) — built and wired into `setup-container-host.sh` (`ENABLE_DIGEST_ENGINE`, off by default), not yet run against real credentials; household/calendar ingest (CalDAV/Grocy) still needs a real data source wired in, see `docs/project-plan.md` §4 ## Quick start @@ -55,9 +62,13 @@ skip straight to Phase 4+ automations before the Phase 2 reactive baseline The script brings up everything that runs on this one Debian host: Home Assistant, Mosquitto, Zigbee2MQTT, Node-RED, Frigate, Grocy, Netdata, a Homepage dashboard, ntfy, and Portainer, plus an optional scheduled restic -backup timer. What it can't do for you, because they need separate hardware, -credentials, or physical setup: pairing Zigbee sensors, flashing -RuView/ESPHome/Bermuda BLE proxy boards, pointing Frigate at a real camera -RTSP URL, the Grocy kiosk touchscreen, the separate LLM/GPU host, and wiring -up the Nextcloud CalDAV integration — see the Status checklist above and -`docs/project-plan.md` for those. +backup timer and an optional quarter-daily digest engine (`ENABLE_DIGEST_ENGINE`, +off by default — needs `digest-engine/` checked out on the host and its `.env` +filled in first, see `digest-engine/README.md`). What it can't do for you, +because they need separate hardware, credentials, or physical setup: pairing +Zigbee sensors, flashing RuView/ESPHome/Bermuda BLE proxy boards, pointing +Frigate at a real camera RTSP URL, the Grocy kiosk touchscreen, the separate +LLM/GPU host, wiring up the Nextcloud CalDAV integration, building/flashing +the thin-client ISO (`hosts/thin-client/`), and provisioning real credentials +for the digest engine's mail/message/news/financial sources — see the Status +checklist above and `docs/project-plan.md` for those. diff --git a/digest-engine/.dockerignore b/digest-engine/.dockerignore new file mode 100644 index 0000000..b7cd379 --- /dev/null +++ b/digest-engine/.dockerignore @@ -0,0 +1,10 @@ +output/ +render/ +whatsapp-bridge/ +README.md +compose-fragment.yaml.txt +digest-engine.env.example +**/__pycache__/ +**/*.pyc +**/*.session +**/*.session-journal diff --git a/digest-engine/Dockerfile b/digest-engine/Dockerfile new file mode 100644 index 0000000..6555513 --- /dev/null +++ b/digest-engine/Dockerfile @@ -0,0 +1,26 @@ +# digest-engine — the first locally-built image in the container-host stack +# (everything else in docs/project-plan.md Phase 1/9 pulls a prebuilt registry image). +# +# Runs as a oneshot: `docker compose run --rm digest-engine`, driven by the +# smart-home-digest.timer systemd timer (4x/day), not as a long-lived service. +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY run.py ./ +COPY ingest/ ./ingest/ +COPY synth/ ./synth/ +COPY feeds/ ./feeds/ + +# /output holds per-run artifacts, /data holds session state written by the +# one-time interactive logins (Telegram) and the whatsapp-bridge sidecar. +# Both are bind-mounted in compose; created here so a bare `docker run` works too. +RUN mkdir -p /output /data + +CMD ["python", "run.py"] diff --git a/digest-engine/IDSconf.json.example b/digest-engine/IDSconf.json.example new file mode 100644 index 0000000..90a3a1d --- /dev/null +++ b/digest-engine/IDSconf.json.example @@ -0,0 +1,90 @@ +{ + "_readme": [ + "Template for IDSconf.json — the OPNsense intrusion-detection config used by", + "digest-engine/ingest/opnsense_ids.py. JSON has no comment syntax, so the", + "documentation lives in these underscore-prefixed keys; the module ignores", + "every key it does not know about, so you can leave them in place.", + "", + "INSTALL:", + " cp digest-engine/IDSconf.json.example /opt/smart-home/digest/data/IDSconf.json", + " chmod 600 /opt/smart-home/digest/data/IDSconf.json", + " $EDITOR /opt/smart-home/digest/data/IDSconf.json", + "That directory is already bind-mounted into the container as /data, which is", + "where OPNSENSE_IDS_CONF_PATH points by default. The real IDSconf.json is", + "git-ignored (root .gitignore) exactly like digest-engine.env — only this", + "template is ever committed. Then set ENABLE_OPNSENSE_IDS_INGEST=true in", + "digest-engine.env; it is false by default.", + "", + "ON OPNSENSE, BEFORE THIS WORKS:", + " 1. Suricata is core, not a plugin — no os-suricata install is needed. Enable", + " it at Services -> Intrusion Detection -> Administration: tick Enabled,", + " pick the interface(s) to watch, leave IPS mode off unless you want it,", + " then Download -> select rulesets (ET Open is free) -> Download & Update.", + " If Suricata is not enabled and running, this module reports ids_status", + " and no alerts, which is the honest answer rather than a silent zero.", + " 2. Create an API key: System -> Access -> Users -> (pick or create a user)", + " -> API keys -> +. OPNsense downloads a .txt holding the key and secret;", + " they are shown once. Auth is HTTP basic with key as username, secret as", + " password. Paste them below.", + " 3. Scope that user: give it ONLY the 'Services: Intrusion Detection'", + " privilege, no shell access, no other pages. READ THIS HONESTLY: OPNsense", + " ACLs are page-level, not read/write-level. That single privilege matches", + " 'api/ids/*', which includes start/stop/reconfigure/drop-alert-log as well", + " as the alert query. There is no narrower built-in privilege. The", + " read-only guarantee therefore comes from opnsense_ids.py never calling", + " those endpoints, not from OPNsense enforcing it — so keep this key off", + " any account that has other privileges, and treat it as a credential that", + " could restart your IDS if it leaked." + ], + + "_base_url": "Scheme + host (+ port if not 443) of the OPNsense web GUI. No trailing path.", + "base_url": "https://opnsense.example.lan", + + "_api_key": "From System -> Access -> Users -> API keys. Sent as HTTP basic auth.", + "api_key": "", + "api_secret": "", + + "_verify_tls": [ + "true (default), false, or a path to a CA bundle inside the container.", + "OPNsense ships a self-signed certificate, so a stock install will fail TLS", + "verification. Prefer copying the firewall's CA into the digest data volume", + "and putting its path here over setting false: the container host and the", + "firewall are on the same flat LAN (no VLAN segmentation is implemented in", + "this project yet — see docs/project-plan.md), so nothing else is protecting", + "this credential in transit." + ], + "verify_tls": true, + + "_interfaces": [ + "Optional allow-list of raw device names to keep alerts from, e.g.", + "[\"igb0\", \"vtnet1\"]. These are the kernel device names Suricata writes to", + "eve.json as in_iface, NOT the friendly OPNsense names (LAN/WAN) — check", + "Interfaces -> Assignments for the mapping. Empty means every interface", + "Suricata is watching." + ], + "interfaces": [], + + "_max_alerts_scanned": [ + "Upper bound on how many alerts one run reads. The OPNsense API has no", + "server-side time filter, so the digest window is applied client-side by", + "paging newest-first until a row falls out of it; this caps that walk. If it", + "is hit, the digest says so (window_truncated) instead of pretending the", + "count is complete." + ], + "max_alerts_scanned": 5000, + + "_top_signatures": "How many distinct signatures to put in the digest context.", + "top_signatures": 8, + "_top_hosts": "How many local and remote IPs to put in the digest context.", + "top_hosts": 5, + + "_packet_capture_reference": [ + "Optional free-text pointer, echoed verbatim into the digest, for raw packet", + "captures kept ON OPNSENSE. This module never starts, downloads or analyses a", + "capture — starting one is a write action on the firewall and is barred by the", + "read-only invariant. See the intrusion-detection section of", + "digest-engine/README.md for how to keep a rotating local capture instead.", + "Example: \"rotating 10-minute captures on OPNsense at /var/log/captures/\"" + ], + "packet_capture_reference": "" +} diff --git a/digest-engine/README.md b/digest-engine/README.md new file mode 100644 index 0000000..d7958d1 --- /dev/null +++ b/digest-engine/README.md @@ -0,0 +1,343 @@ +# digest-engine + +The quarter-daily LLM digest from [Phase 12 of the project plan](../docs/project-plan.md). + +Four times a day it ingests mail, messages, news and financial data, sends the +lot to the existing Ollama host for synthesis into three sections — **personal**, +**political** and **household** — and writes a rendered digest that `digest-web` +serves to two surfaces: the thin client's kiosk Firefox workspace (full view) and +a Home Assistant Lovelace iframe card (compact view). + +It is a **oneshot**, not a daemon: a systemd timer runs +`docker compose run --rm digest-engine`, exactly like the restic backup job. + +**Everything here is read-only.** No replies, no marking mail read or archived, no +calendar or Grocy writes, no message-platform writes of any kind. There is no +mutation path in this component by construction — that is a hard requirement from +the plan, not a default. + +## Layout + +``` +run.py oneshot entrypoint +ingest/ one module per source, each `fetch(lookback_hours) -> list[dict]` + telegram_login.py standalone one-time interactive login (run by hand) +synth/llm_client.py Ollama client + the digest JSON schema +synth/prompts/ one prompt template per section +render/digest-canvas-sdk/ vendored, offline JS/CSS — globe, window chrome, glow, renderer +render/templates/ compact.html (HA iframe) and full.html (kiosk) +feeds/curated-feeds.opml the news feed list — edit this +IDSconf.json.example OPNsense IDS config template (real file gitignored) +whatsapp-bridge/ Node.js sidecar, opt-in, see the warning below +output/ per-run artifacts (gitignored) +compose-fragment.yaml.txt compose blocks to splice into setup-container-host.sh +``` + +## Configure + +```sh +cp digest-engine/digest-engine.env.example /opt/smart-home/digest/digest-engine.env +chmod 600 /opt/smart-home/digest/digest-engine.env +$EDITOR /opt/smart-home/digest/digest-engine.env +``` + +Every source is off by default. Turn on only what you have credentials for — a +disabled or misconfigured source logs a warning and contributes nothing, and can +never take the rest of the run down with it. + +Then edit `feeds/curated-feeds.opml`: the mainstream outlets in it are a +clearly-marked placeholder list, only the `marxist.com` feed is a deliberate +choice (the political prompt uses it as its analytical basis). + +## One-time steps before the first real run + +Both of these are interactive and must be done by hand, once. Scheduled runs +never prompt for anything. + +**Telegram** — creates the session file `telegram_ingest.py` then reuses +non-interactively. You will be asked for your phone number, the code Telegram +sends, and your 2FA password if the account has one: + +```sh +docker compose run --rm --entrypoint python digest-engine ingest/telegram_login.py +``` + +**WhatsApp** (only if you have opted in) — start the bridge and scan the QR code +it prints to its own logs with **WhatsApp -> Linked devices -> Link a device**: + +```sh +docker compose up -d whatsapp-bridge +docker compose logs -f whatsapp-bridge +``` + +The session persists in the bridge's `/data/.wwebjs_auth` volume, so this is a +one-time scan unless WhatsApp invalidates the link. + +## Run once, manually + +```sh +docker compose run --rm digest-engine +``` + +Output lands in `output//`: + +- `context.json` — the ingested context bundle, kept for the Phase 12 follow-up + voice Q&A (a spoken follow-up re-queries Ollama against this rather than + re-ingesting). +- `digest.json` — the rendered digest, both detail levels, all three sections. + +`output/latest.json` is rewritten with the same payload and `output/latest` +re-pointed at the newest run directory, so `digest-web` always serves the current +digest with no coordination with the scheduler. + +## WhatsApp — read this before enabling + +There is **no officially sanctioned way** to read your own WhatsApp messages +programmatically. `whatsapp-bridge` runs a real Chromium logged into +web.whatsapp.com as a linked device, deliberately **headful** under Xvfb because +WhatsApp's automation detection specifically fingerprints headless Chrome. That +is a meaningful mitigation. It is **not** immunity: this is still automated use of +a personal account and accounts do get banned for it, historically on a ~2–8 week +timescale. + +If you enable it: + +- Use a **secondary, non-critical number**, not your main one. +- Accept that the number may be banned, and that this is the highest-risk of the + four message platforms by a wide margin. +- Keep `ENABLE_WHATSAPP_INGEST=false` if you are at all unsure. The other three + sections work fine without it. + +## Manual verification still outstanding + +None of this has been run against real credentials or real accounts. Before +trusting a scheduled run, verify by hand: + +1. Each source in isolation, e.g. + `docker compose run --rm --entrypoint python digest-engine -c "import logging,os; logging.basicConfig(level='INFO'); from ingest import news_rss; print(len(news_rss.fetch(6)))"`. +2. That the IMAP mailbox shows **no** newly-read messages after a run (the folder + is opened `readonly=True`, but confirm it against your provider). +3. That the Ollama model actually honours `format: "json"` and the schema — check + `output//digest.json` for `"degraded": true`, which marks a section that + fell back to plain text. +4. That both templates render: open `http://:8091/full.html` and + `http://:8091/compact.html`. +5. The malformed-output fallback, by hand-editing `output/latest.json` into + invalid JSON and reloading — the page must show a `
` dump, never a blank
+   screen.
+6. The feed URLs in `curated-feeds.opml` — several mainstream outlets have
+   changed or restricted their public RSS.
+7. That the Nextcloud app password works over CalDAV and that recurring events
+   land on the right day (the expansion path above is the one most likely to
+   differ between Nextcloud versions).
+8. The evening recipe, with `DIGEST_FORCE_EVENING=true` on a manual run — then
+   confirm in Grocy that **nothing** was added to its shopping list and no stock
+   moved.
+9. The unviewed-digest merge: run once, do NOT show it on a thin client, run again
+   (or set `DIGEST_LOOKBACK_HOURS`/wait) — confirm the second run's `context.json`
+   has `"merged_unviewed_previous_run": true` and its digest actually carries
+   forward the first run's content. Then show a digest on a thin client and run a
+   third time — confirm that one merges nothing.
+
+## Traffic data — what exists and what does not
+
+`ingest/flight_traffic.py` and `ingest/naval_traffic.py` feed the **political**
+section as extra `category`-tagged evidence, alongside news and financial data.
+They are not a new digest section, and `synth/prompts/political.md` is explicitly
+told to drop them when they corroborate nothing. Both are off by default.
+
+**Air traffic** — OpenSky Network, bounded boxes over configured regions.
+FlightRadar24 and ADS-B Exchange were not used: FR24 prohibits scraping and sells
+API access, and ADS-B Exchange ended its freemium RapidAPI tier on 2025-03-01
+(paid from $10/month). Before enabling, read the licensing note in
+`digest-engine.env.example` — OpenSky's Terms of Use require a prior written
+agreement for use of the REST API "in any operational capacity", which arguably
+covers a timer-driven digest. Attribution to OpenSky is required.
+
+**Naval traffic** — aisstream.io, a free keyed WebSocket stream, sampled briefly
+per run. Read what it is for before enabling it: **AIS cannot show naval force
+posture.** Warships sail with AIS off routinely, and the "military ops" AIS type
+code is self-declared. What it shows is *merchant* traffic through chokepoints,
+which is genuinely useful in the negative — shipping abandoning a route arrives
+as freight, insurance and fuel costs. AISHub was not used: it is still
+contribute-to-access and needs an AIS receiver this project does not have.
+MarineTraffic, VesselFinder and Spire are paid.
+
+**Military movement — deliberately not built as a data source.** There is no free
+structured feed of military movements. ACLED is retrospective conflict-*event*
+data, needs registration, and its EULA forbids redistribution and non-transformative
+derivative works; UCDP is keyless but lags by roughly a month; everything
+real-time is either commercial or a person on social media. Rather than invent an
+integration, the military-movement signal comes from OSINT/defence outlets added
+to `feeds/curated-feeds.opml` under `category="osint_military"`, which reuses the
+existing news ingestion with no new code. Verified live 2026-07-28; Liveuamap has
+no free RSS feed (`/rss` is a paid-API signup page) and ISW and Long War Journal
+both 403'd the check — re-test those from the real network.
+
+## Home network intrusion detection — what exists and what does not
+
+`ingest/opnsense_ids.py` pulls a summary of the Suricata alerts your **existing
+OPNsense firewall** raised during the digest window and folds it into the
+**household** section as one short item, tagged `"category":
+"network_security"`. Off by default (`ENABLE_OPNSENSE_IDS_INGEST=false`);
+configured by `IDSconf.json` (template: `IDSconf.json.example`, real file
+gitignored, same pattern as `digest-engine.env`).
+
+**Suricata is core, not a plugin.** There is no `os-suricata` to install — the
+IDS module ships in OPNsense core and lives at Services → Intrusion Detection.
+The `os-intrusion-detection-content-*` plugins are ruleset *content* only, and
+ET Open needs none of them. Suricata is nonetheless **off** on a stock install;
+enable it and download a ruleset first, or every run will report `ids_status`
+and no alerts, which is the honest answer and not a quiet network.
+
+**It is a pull, like every other source here.** Two endpoints, both read-only in
+effect: `GET /api/ids/service/status` and `POST /api/ids/service/query_alerts`.
+The latter is a POST only because that is how OPNsense routes filtered queries —
+it runs `queryAlertLog.py`, which reads `/var/log/suricata/eve.json` backwards.
+No SSH or file access to the firewall is needed, and no push agent runs on it.
+
+Three limits worth knowing before you read the output:
+
+- **No server-side time filter.** `searchPhrase` matches signature/action/src/dst
+  text only. Rows come back newest-first, so the window is applied client-side by
+  paging until a row falls out of it, bounded by `max_alerts_scanned`. When that
+  bound is hit — or when the alert log rotated mid-window — the entry carries
+  `window_truncated: true` and the prompt is told the counts are a lower bound.
+- **No severity.** OPNsense flattens each eve.json record to signature + SID +
+  action before returning it, discarding `alert.severity` and `alert.category`.
+  `get_alert_info` uses the same flattening, so it does not help. Alerts are
+  ranked by frequency, and the household prompt is told it cannot see severity.
+- **Page-level ACLs.** The "Services: Intrusion Detection" privilege matches
+  `api/ids/*`, which covers start/stop/reconfigure/drop-alert-log as well as the
+  alert query. OPNsense has no narrower built-in privilege, so the read-only
+  guarantee is enforced by this code (which calls two endpoints and no others)
+  and not by the firewall. Give the API key its own user with that one privilege
+  and nothing else, and treat it as a credential that could restart your IDS if
+  it leaked — the container host and the firewall are on the same flat LAN, since
+  no VLAN segmentation is implemented in this project yet.
+
+**Raw packet captures — deliberately not done here.** OPNsense does expose
+Interfaces: Diagnostics: Packet Capture over the API
+(`/api/diagnostics/packet_capture/{set,start,stop,remove}`), but every one of
+those is a POST that writes a job file and spawns `tcpdump` on the firewall.
+Starting a capture is a write action on someone else's router and is barred by
+this component's read-only invariant. Downloading and parsing pcaps into the
+digest would also mean hand-rolling malware detection over raw packets, which is
+strictly worse than reading the verdicts of a maintained ruleset that already
+inspected the same traffic in real time.
+
+If you still want a rotating raw capture for manual inspection, keep it **on
+OPNsense**, e.g. a `tcpdump -G 600 -W 12 -w /var/log/captures/cap-%F-%H%M.pcap`
+rotation driven from the firewall's own cron (this needs shell access on
+OPNsense — the GUI cron only schedules predefined configd actions — and enough
+disk for ten-minute captures of a live link, which is not small). Then put a
+one-line pointer in `IDSconf.json`'s `packet_capture_reference`; it is echoed
+verbatim into the digest so the household section can say "raw captures are at
+X". The digest engine never downloads, stores or analyses them.
+
+## Household data — what exists and what does not
+
+`ingest/caldav.py` and `ingest/grocy.py` are what the household section actually
+runs on. Both are off by default and both need one credential created by hand.
+
+**Calendar** — Nextcloud over CalDAV (Phase 8), via the maintained `caldav`
+library rather than hand-written REPORT XML. Auth is a **Nextcloud app
+password** (Settings → Security → Devices & sessions → Create new app password),
+not the account password and not OAuth2 — mandatory once 2FA is on, since the DAV
+endpoints cannot prompt for a second factor, and revocable on its own regardless.
+Point `CALDAV_URL` at the DAV root (`https:///remote.php/dav`) and the
+client discovers the principal's calendars from there.
+
+The window is deliberately asymmetric — `DIGEST_LOOKBACK_HOURS` backwards, so
+this morning's appointment and anything still running are still visible, and
+`CALDAV_LOOKAHEAD_HOURS` (default 48) forwards, because a calendar is mostly
+useful in the future tense. Recurring events are requested **expanded**, so a
+weekly standup arrives as the occurrence in this window rather than as the master
+event with an RRULE; if a server rejects expansion outright, the search is
+retried without it and a recurring series shows up as its master event.
+
+**Kitchen inventory** — Grocy (Phase 7), reached at `http://grocy` on the shared
+compose network (port 80 inside the container; the published 9283 is not
+involved). One endpoint does most of the work: `GET /api/stock/volatile`, which
+returns `due_products`, `overdue_products`, `expired_products` and
+`missing_products` directly. **Watch the naming** — Grocy renamed
+`expiring_products` → `due_products` in v3.0.0, so older third-party examples are
+wrong against a current install. Chores and batteries come from `GET /api/chores`
+and `GET /api/batteries`; both use `2999-12-31 23:59:59` as a "no schedule"
+sentinel, which is filtered out rather than reported as a due date.
+
+Auth is a `GROCY-API-KEY` header, generated at Grocy → Settings → Manage API
+keys. **A Grocy API key is not scoped**: it carries that user's full read *and*
+write rights, so give this one its own Grocy user, and note that the read-only
+guarantee is enforced by `ingest/grocy.py` calling nothing but GETs — the
+module's docstring names every write endpoint it deliberately does not use — and
+not by Grocy.
+
+**No compose or systemd changes were needed for either.** digest-engine and
+grocy are already on the same default compose network, so the container name
+resolves; Nextcloud is external and reached over its normal URL; and the
+credentials are ordinary env vars in the `digest-engine.env` the service already
+loads.
+
+## The evening recipe suggestion
+
+On one run a day — `DIGEST_EVENING_HOUR`, default `18` — the household section
+also suggests a dish built around whatever Grocy says is about to go off, plus a
+shopping list for the ingredients that dish needs and the house does not have.
+The other three runs omit it entirely rather than padding it in.
+
+**It is a suggestion, and nothing else.** Nothing is written to Grocy: no item is
+added to its shopping list, no stock is consumed, no order is placed anywhere.
+Grocy's API supports all of that with the same key and this component uses none
+of it, per the read-only invariant above. You read the list and go shopping.
+
+Which run is "evening" is derived from the container's local wall clock, not
+passed in by the caller. The systemd unit runs a bare
+`docker compose run --rm digest-engine` with no arguments and a manual run is the
+same command, so an argument or a unit-specific env var would have to be threaded
+through both and would silently misbehave on a hand-run digest; the container
+already has the host's `TZ` and `/etc/localtime`, which is the same clock the
+timer's `OnCalendar` fires against. The run is attributed to the most recent
+`DIGEST_SCHEDULE` slot at or before now rather than to an exact hour match,
+because the timer is `Persistent=true` — a host asleep at 18:00 fires late, and
+an exact match would drop the feature on precisely the days the digest is late.
+
+Set `DIGEST_FORCE_EVENING=true` for a one-off run to test it at any hour.
+Keep `DIGEST_SCHEDULE` in step with the variable of the same name in
+`hosts/container-host/scripts/setup-container-host.sh`, which is what sets the
+timer.
+
+## Merging an unviewed digest into the next one
+
+If nobody actually looked at a run before the next one was due, its content is
+folded into the new run instead of being silently thrown away — see
+`viewed_tracker.py`, `run.py`'s `should_merge()`/`previous_section_document()`, and
+the merge instruction `synth/llm_client.py` adds to the prompt when it applies.
+
+**"Viewed" means a thin client actually displayed the full canvas** — the "Show
+digest canvas" button or a voice-resolved "play my digest" request, both of which go
+through `thinclient_agent/main.py`'s `on_show_digest()`, which publishes a retained
+`{"viewed_at": ...}` to `smarthome/digest/viewed` on the same Mosquitto broker
+everything else in this project already shares. **The compact HA-dashboard iframe
+view does not count** — it's a browser rendering a static page, with no path back to
+MQTT at all, so leaving it open on a phone can never mark a digest viewed.
+
+Each run compares that timestamp against the previous run's `generated_at`
+(`output/latest/digest.json`). If the previous run is newer than the last time
+anything was viewed — or nothing has ever been marked viewed, or no previous run
+exists yet — this run proceeds exactly as before. Otherwise, each section's own
+previous content (from the `full` detail level, the richest version) is handed to
+that section's synthesis pass as `previous_unviewed_digest`, with an instruction to
+combine it with the new material into one digest rather than repeating or discarding
+either — nothing is dropped, but nothing doubles up either.
+
+If MQTT is unreachable, `paho-mqtt` isn't installed, or the retained message can't be
+parsed, `viewed_tracker.last_viewed_at()` returns `None`, which is treated the same
+as "viewed" — the safer of the two wrong answers, since it costs at most one merge
+that should have happened, rather than gluing every future run onto the last
+forever. `MQTT_VIEWED_WAIT_SECONDS` (default 3) bounds how long a run will wait for
+that retained message before moving on, so a dead broker never stalls a digest run.
+
+**Not yet run against a real broker or a real thin client** — the retained-message
+round trip, the `on_show_digest` publish, and a genuine multi-cycle unviewed→merged
+sequence are all still on the manual-verification list.
diff --git a/digest-engine/digest-engine.env.example b/digest-engine/digest-engine.env.example
new file mode 100644
index 0000000..8bf3524
--- /dev/null
+++ b/digest-engine/digest-engine.env.example
@@ -0,0 +1,301 @@
+# digest-engine configuration template.
+#
+# Copy this to the container host as (for example) /opt/smart-home/digest/digest-engine.env,
+# fill in the real values, and `chmod 600` it. That file is what the compose
+# service loads via env_file; it must never be committed (the repo .gitignore
+# already covers .env / *.env, keeping this in line with the restic-password
+# convention in hosts/container-host/scripts/setup-container-host.sh).
+#
+# Every toggle below is the string "true" or "false", matching the ENABLE_X
+# convention used throughout this repo.
+
+# ---------------------------------------------------------------------------
+# Run behaviour
+# ---------------------------------------------------------------------------
+# How far back each source looks. Should match the digest cadence (4x/day = 6h).
+DIGEST_LOOKBACK_HOURS=6
+DIGEST_OUTPUT_DIR=/output
+LOG_LEVEL=INFO
+
+# Which slot a run belongs to is derived from the container's local wall clock —
+# it is NOT passed in by systemd, because the timer runs a bare
+# `docker compose run --rm digest-engine` and a manual run is the same command.
+# Keep DIGEST_SCHEDULE in step with DIGEST_SCHEDULE in
+# hosts/container-host/scripts/setup-container-host.sh, which is what actually
+# sets the timer's OnCalendar.
+DIGEST_SCHEDULE=00,06,12,18
+# The "I'm done working" slot. Only this run gets the recipe + shopping-list
+# suggestion in the household section; the other three omit it entirely. Must be
+# one of the DIGEST_SCHEDULE hours or no run will ever qualify.
+DIGEST_EVENING_HOUR=18
+# Testing override — makes any run behave as the evening run, so the recipe
+# feature can be checked without waiting for 18:00. Leave false in production.
+DIGEST_FORCE_EVENING=false
+
+# ---------------------------------------------------------------------------
+# LLM synthesis — the existing Phase 3 Ollama host
+# ---------------------------------------------------------------------------
+OLLAMA_HOST=http://llm-host:11434
+OLLAMA_MODEL=qwen2.5:14b-instruct
+OLLAMA_TIMEOUT=600
+OLLAMA_TEMPERATURE=0.4
+
+# ---------------------------------------------------------------------------
+# "Was the last digest viewed?" — see viewed_tracker.py. Same Mosquitto broker
+# every HA/thinclient-agent already uses; digest-engine only ever subscribes,
+# never publishes, to the smarthome/digest/viewed topic. Defaults assume the
+# same-Docker-network reachability every other container-host service relies on.
+# ---------------------------------------------------------------------------
+MQTT_BROKER_HOST=mosquitto
+MQTT_BROKER_PORT=1883
+MQTT_USERNAME=
+MQTT_PASSWORD=
+# How long to wait for a retained message before giving up and assuming "viewed"
+# (the safe default — see viewed_tracker.last_viewed_at()'s docstring).
+MQTT_VIEWED_WAIT_SECONDS=3
+
+# ---------------------------------------------------------------------------
+# Ingestion toggles — every source is off until you have provisioned its
+# credentials. A disabled or misconfigured source logs a warning and contributes
+# nothing; it never fails the run.
+# ---------------------------------------------------------------------------
+ENABLE_EMAIL_INGEST=false
+ENABLE_SIGNAL_INGEST=false
+ENABLE_TELEGRAM_INGEST=false
+ENABLE_DISCORD_INGEST=false
+# Off by default — read digest-engine/README.md before enabling, real ban risk
+# even with the headful-Chromium mitigation. Use a secondary/non-critical number.
+ENABLE_WHATSAPP_INGEST=false
+ENABLE_NEWS_INGEST=true
+ENABLE_FINANCIAL_INGEST=true
+# Off by default on purpose — read the OpenSky terms-of-use note further down
+# before turning this on, it is a licensing decision, not a technical one.
+ENABLE_FLIGHT_TRAFFIC_INGEST=false
+# Off by default because it does nothing without AISSTREAM_API_KEY.
+ENABLE_NAVAL_TRAFFIC_INGEST=false
+# Off by default on purpose — this one talks to your firewall. Read the OPNsense
+# section at the bottom of this file and IDSconf.json.example before enabling.
+ENABLE_OPNSENSE_IDS_INGEST=false
+# The two household sources. Off until you have made a Grocy API key and a
+# Nextcloud app password — see their sections below.
+ENABLE_CALDAV_INGEST=false
+ENABLE_GROCY_INGEST=false
+
+# ---------------------------------------------------------------------------
+# Email (IMAP)
+# ---------------------------------------------------------------------------
+# Auth is an app-specific password, NOT OAuth2. For Gmail: enable 2FA, then
+# generate one at myaccount.google.com -> Security -> App passwords. Other
+# providers have an equivalent. The mailbox is opened read-only, so nothing is
+# ever marked \Seen, archived or deleted.
+EMAIL_IMAP_HOST=imap.gmail.com
+EMAIL_IMAP_PORT=993
+EMAIL_USERNAME=you@example.com
+EMAIL_PASSWORD=
+EMAIL_FOLDER=INBOX
+# Overrides DIGEST_LOOKBACK_HOURS for mail only; leave unset to inherit it.
+#EMAIL_LOOKBACK_HOURS=6
+
+# ---------------------------------------------------------------------------
+# Signal
+# ---------------------------------------------------------------------------
+# digest-engine does NOT run signal-cli. Add your own signal-cli JSON-RPC daemon
+# as a separate compose service on the same network (linked as a secondary device
+# to your account) and point this at it. See
+# https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc
+SIGNAL_CLI_URL=http://signal-cli:8080
+# Your registered number in E.164 form; only needed if the daemon serves more
+# than one account.
+SIGNAL_ACCOUNT=
+SIGNAL_RECEIVE_TIMEOUT=10
+
+# ---------------------------------------------------------------------------
+# Telegram (Telethon / MTProto userbot)
+# ---------------------------------------------------------------------------
+# Get the API id/hash from https://my.telegram.org -> API development tools.
+# The session file is created ONCE, interactively:
+#   docker compose run --rm --entrypoint python digest-engine ingest/telegram_login.py
+TELEGRAM_API_ID=
+TELEGRAM_API_HASH=
+TELEGRAM_SESSION_PATH=/data/telegram.session
+TELEGRAM_MAX_DIALOGS=25
+TELEGRAM_MAX_MESSAGES_PER_DIALOG=50
+
+# ---------------------------------------------------------------------------
+# Discord
+# ---------------------------------------------------------------------------
+# A bot token from https://discord.com/developers/applications, with the
+# "Message Content" privileged gateway intent enabled. The bot can only ever see
+# guilds it was invited to — Discord enforces that, not this code.
+DISCORD_BOT_TOKEN=
+DISCORD_MAX_MESSAGES_PER_CHANNEL=50
+DISCORD_TIMEOUT=120
+
+# ---------------------------------------------------------------------------
+# WhatsApp (whatsapp-bridge sidecar)
+# ---------------------------------------------------------------------------
+# Path, inside the digest-engine container, of the JSON-lines file the bridge
+# writes. Drained (renamed, read, deleted) once per run.
+WHATSAPP_MESSAGES_PATH=/data/whatsapp-bridge/messages.jsonl
+
+# ---------------------------------------------------------------------------
+# News
+# ---------------------------------------------------------------------------
+NEWS_OPML_PATH=/app/feeds/curated-feeds.opml
+NEWS_MAX_ENTRIES_PER_FEED=15
+
+# ---------------------------------------------------------------------------
+# Financial
+# ---------------------------------------------------------------------------
+# Free FRED API key: https://fred.stlouisfed.org/docs/api/api_key.html
+FRED_API_KEY=
+# Comma-separated FRED series IDs. Default UNRATE = US unemployment rate.
+# Browse more at https://fred.stlouisfed.org (e.g. UNRATE,CPIAUCSL,FEDFUNDS).
+FRED_SERIES=UNRATE
+# Comma-separated Stooq symbols (keyless CSV, no rate cap worth worrying about).
+# ^spx / ^dax / ^ndq = indices, cl.f = WTI crude futures, gc.f = gold,
+# aapl.us style = individual US equities.
+STOOQ_SYMBOLS=^spx,^dax,cl.f
+
+# ---------------------------------------------------------------------------
+# Air traffic (OpenSky Network)
+# ---------------------------------------------------------------------------
+# Extra evidence for the political section, not a section of its own.
+#
+# LICENSING — decide this before setting ENABLE_FLIGHT_TRAFFIC_INGEST=true.
+# OpenSky's Terms of Use (https://opensky-network.org/about/terms-of-use) license
+# the data for non-profit research/education and personal use, and say that using
+# the REST API "in any operational capacity — including integration into a live
+# product, service, or automated system (even if only internal)" needs a prior
+# written agreement. A timer-driven digest is arguably such a system. Personal,
+# non-commercial household use is the intended case here; if that is not you,
+# mail contact@opensky-network.org first. Attribution to OpenSky is required.
+#
+# RATE LIMITS — a daily credit budget per endpoint. Anonymous (by IP): 400/day,
+# current state only, 10s resolution. With client credentials: 4,000/day, 5s
+# resolution. Running an ADS-B feeder: 8,000/day. A /states/all call costs 1
+# credit for a box <=25 sq deg, 2 for 25-100, 3 for 100-400, 4 for global. The
+# default region list costs ~12 credits/run, ~48/day at 4 runs/day — fine even
+# anonymously, which is why the credentials below are optional.
+#
+# Optional. Create an API client at https://opensky-network.org/my-opensky (the
+# account page). Username/password basic auth stopped working on 2026-03-18.
+OPENSKY_CLIENT_ID=
+OPENSKY_CLIENT_SECRET=
+# REVIEW THIS LIST. Semicolon-separated `Name:lamin,lomin,lamax,lomax` boxes in
+# decimal degrees. What ships below is a placeholder set of currently-tense
+# regions, in the same spirit as the placeholder outlets in curated-feeds.opml —
+# conflict zones move and nothing here updates itself. Keep boxes small: cost per
+# call and context size both scale with area.
+FLIGHT_REGIONS=Eastern Mediterranean / Levant:30,32,37,37;Black Sea / Ukraine:44,29,53,41;Persian Gulf / Strait of Hormuz:24,50,30,60;Red Sea / Bab el-Mandeb:12,38,20,45;Taiwan Strait:21,117,27,124;Baltic / Kaliningrad:53,17,60,28
+# Callsign prefixes treated as military. A heuristic that only catches aircraft
+# flying under published national callsigns (REACH/ASCOT/CANFORCE and friends);
+# anything with its transponder off is invisible to ADS-B entirely.
+FLIGHT_MILITARY_CALLSIGN_PREFIXES=RCH,CNV,RRR,CFC,GAF,IAM,FAF,BAF,NAF,PLF,HAF,NATO,SVF
+FLIGHT_MAX_MILITARY_PER_REGION=20
+
+# ---------------------------------------------------------------------------
+# Naval traffic (aisstream.io)
+# ---------------------------------------------------------------------------
+# Free key, no card, from https://aisstream.io after signing in (GitHub etc.).
+# The service is WebSocket-only and self-describes as beta with no uptime SLA, so
+# a run that returns nothing is normal rather than broken.
+#
+# What this measures: civil merchant traffic through chokepoints. What it does
+# NOT measure: naval force posture. Warships sail with AIS off as a matter of
+# routine and the AIS "military ops" type code is self-declared, so a quiet box
+# here means nothing at all about warships. It is useful for the opposite
+# reading — merchant traffic abandoning a route, which shows up in freight and
+# insurance costs and pairs with the financial indicators above.
+AISSTREAM_API_KEY=
+# Same `Name:lamin,lomin,lamax,lomax` format as FLIGHT_REGIONS.
+NAVAL_REGIONS=Red Sea / Bab el-Mandeb:12,38,20,45;Strait of Hormuz:24,54,28,58;Black Sea:41,27,47,42;Taiwan Strait:21,117,27,124;Suez Canal approaches:29,32,32,34
+# Seconds the socket is held open per run. This is a density sample, not a
+# census; raising it makes a oneshot digest sit on a socket for longer.
+NAVAL_SAMPLE_SECONDS=20
+
+# ---------------------------------------------------------------------------
+# Household calendar (Nextcloud CalDAV, Phase 8)
+# ---------------------------------------------------------------------------
+# Auth is a Nextcloud APP PASSWORD, not your account password and not OAuth2 —
+# same reasoning as the IMAP section above. Create one at Nextcloud -> Settings ->
+# Security -> Devices & sessions -> Create new app password. It is mandatory once
+# two-factor authentication is enabled (the DAV endpoints cannot prompt for a
+# second factor) and it is revocable on its own if this host is ever compromised.
+#
+# Point CALDAV_URL at the DAV root; the client discovers the principal and its
+# calendars from there. The per-user form
+# https://cloud.example.com/remote.php/dav/principals/users// also works.
+#
+# Read-only: this only ever issues CalDAV read requests. It never creates, moves
+# or deletes an event.
+CALDAV_URL=https://cloud.example.com/remote.php/dav
+CALDAV_USERNAME=
+CALDAV_PASSWORD=
+# Comma-separated calendar display names to include. Empty = every calendar the
+# account can see (which includes ones shared with you).
+CALDAV_CALENDARS=
+# The calendar window is asymmetric: DIGEST_LOOKBACK_HOURS backwards (to catch
+# what already happened today and anything still in progress) and this forwards.
+CALDAV_LOOKAHEAD_HOURS=48
+CALDAV_MAX_EVENTS=100
+# Only set false for a self-signed internal cert, and understand that it sends
+# the app password over an unverified session.
+CALDAV_VERIFY_TLS=true
+
+# ---------------------------------------------------------------------------
+# Kitchen inventory (Grocy, Phase 7)
+# ---------------------------------------------------------------------------
+# GROCY_URL is the container name on the shared compose network — Grocy listens
+# on port 80 inside the container (published to the LAN as 9283, which this does
+# not need to go through). Use http://:9283 only if Grocy runs elsewhere.
+#
+# The API key is per user: Grocy -> Settings -> Manage API keys -> add. Note that
+# a Grocy key is NOT scoped — it carries that user's full read and write rights,
+# so give this one its own Grocy user. The read-only guarantee comes from
+# ingest/grocy.py issuing GETs and nothing else; see the invariant at the top of
+# that file, which also names the write endpoints it deliberately never calls.
+#
+# Nothing in this component ever adds to Grocy's shopping list, consumes stock,
+# executes a chore or charges a battery — including the evening recipe feature,
+# whose shopping list is a suggestion printed in the digest for you to act on.
+GROCY_URL=http://grocy
+GROCY_API_KEY=
+# Days ahead a product counts as "due soon" (Grocy's /stock/volatile default is 5).
+GROCY_DUE_SOON_DAYS=5
+# Days ahead a chore or battery charge is worth mentioning.
+GROCY_TASK_HORIZON_DAYS=7
+# Cap on the in-stock product list, which is only sent on the evening run so the
+# recipe's shopping list can tell "already have it" from "buy it". Raise it if
+# your pantry is bigger than this, or the shopping list will suggest things you
+# already own (a warning is logged when the cap bites).
+GROCY_MAX_STOCK_ITEMS=200
+
+# ---------------------------------------------------------------------------
+# Home network intrusion detection (OPNsense + Suricata)
+# ---------------------------------------------------------------------------
+# Feeds a short "is anything wrong with the home network" item into the
+# HOUSEHOLD section. Everything except the toggle and the path below lives in
+# IDSconf.json, because that file carries an API key/secret — copy
+# IDSconf.json.example to the digest data volume, chmod 600 it, and read its
+# _readme keys; they cover creating the OPNsense API key and scoping the user.
+#
+# Suricata is part of OPNsense core (no os-suricata plugin to install), but it
+# is OFF on a stock install — enable it at Services -> Intrusion Detection and
+# download a ruleset first, or every run will honestly report that the IDS was
+# not running.
+#
+# This is a pure pull over HTTPS: GET /api/ids/service/status and POST
+# /api/ids/service/query_alerts (a POST that only reads /var/log/suricata/
+# eve.json), and nothing else, ever. It never starts a packet capture, never
+# reconfigures or restarts Suricata, and never clears an alert log.
+#
+# POSTURE NOTE: the container host and the firewall sit on the same flat LAN —
+# no VLAN segmentation is implemented in this project yet (docs/project-plan.md
+# only plans one for cameras). Scope the OPNsense user to the single "Services:
+# Intrusion Detection" privilege and nothing else. Be aware that OPNsense ACLs
+# are page-level: that privilege still covers the IDS start/stop endpoints, so
+# the read-only guarantee comes from this code, not from the firewall.
+#
+# Path INSIDE the container. /data is the bind-mounted digest data volume.
+OPNSENSE_IDS_CONF_PATH=/data/IDSconf.json
diff --git a/digest-engine/feeds/curated-feeds.opml b/digest-engine/feeds/curated-feeds.opml
new file mode 100644
index 0000000..17007df
--- /dev/null
+++ b/digest-engine/feeds/curated-feeds.opml
@@ -0,0 +1,168 @@
+
+
+
+  
+    SmartestHome digest feeds
+  
+  
+    
+    
+      
+      
+      
+    
+
+    
+    
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+    
+
+    
+    
+      
+      
+      
+      
+    
+  
+
diff --git a/digest-engine/ingest/__init__.py b/digest-engine/ingest/__init__.py
new file mode 100644
index 0000000..a9beb5b
--- /dev/null
+++ b/digest-engine/ingest/__init__.py
@@ -0,0 +1,10 @@
+"""Ingestion modules for digest-engine.
+
+Every module here exposes `fetch(lookback_hours: float) -> list[dict]` and is
+independently importable — `python -c 'from ingest import news_rss; print(news_rss.fetch(6))'`
+is a valid way to smoke-test a single source without running a whole digest.
+
+Read-only, always: per docs/project-plan.md Phase 12 step 8, no module in this
+package may perform a write action against any ingested platform (no replies, no
+flag/archive/delete, no read receipts beyond what a protocol mandates to fetch).
+"""
diff --git a/digest-engine/ingest/caldav.py b/digest-engine/ingest/caldav.py
new file mode 100644
index 0000000..533a54d
--- /dev/null
+++ b/digest-engine/ingest/caldav.py
@@ -0,0 +1,183 @@
+"""Nextcloud calendar ingestion over CalDAV.
+
+READ-ONLY INVARIANT: this module only ever issues CalDAV REPORT/PROPFIND reads
+(`Calendar.search()` and the principal/calendar discovery it needs). It never
+calls `save_event`, `add_event`, `Event.delete()`, `make_calendar()` or anything
+else that would mutate the collection, per docs/project-plan.md Phase 12 step 8
+and the Phase 8 "no silent mutation" precedent. The digest can say an event needs
+attention; it can never move, create or delete one.
+
+Auth is a Nextcloud **app password**, not OAuth2 and not the account password —
+the same reasoning email_imap.py documents for Gmail App Passwords. Nextcloud
+issues them at Settings -> Security -> Devices & sessions -> Create new app
+password, and they are mandatory for CalDAV once two-factor authentication is on,
+because the DAV endpoints have no way to prompt for a second factor. Even without
+2FA they are the right credential here: revocable on their own, scoped to this
+one integration, and they leave the account password out of a file on the
+container host.
+
+Sourcing, verified 2026-07-28:
+
+  The protocol is not hand-rolled. `caldav` on PyPI (3.2.1, 2026-05-28, Python
+  >=3.10, https://pypi.org/project/caldav/) does discovery, the calendar-query
+  REPORT and recurrence expansion; hand-writing that XML against a real server is
+  a bad trade. It pulls in `icalendar`, which is what actually parses the
+  returned VEVENTs here.
+
+  URL: point CALDAV_URL at Nextcloud's DAV root, `https:///remote.php/dav`
+  — the library discovers the principal and its calendars from there. Nextcloud
+  documents the equivalent per-user form
+  `https:///remote.php/dav/principals/users//`; either works.
+
+  `search(start=..., end=..., event=True, expand=True)` is the documented way to
+  get a time-bounded list of events with recurrences expanded into concrete
+  occurrences. Without `expand`, a weekly recurring event comes back once, as its
+  original master VEVENT with an RRULE, and the digest would report a meeting on
+  the day it was first created. caldav 2.0+ can expand client-side when the
+  server will not, but sabre/dav servers that reject the request outright are
+  handled by the retry below.
+
+The window is deliberately asymmetric: everything else in this component looks
+backwards over the digest window, but a calendar is mostly useful forwards, so
+this reads `lookback_hours` back (to catch what happened earlier today, and
+events still in progress) and CALDAV_LOOKAHEAD_HOURS forwards.
+"""
+
+import logging
+import os
+from datetime import date, datetime, timedelta, timezone
+
+LOG = logging.getLogger(__name__)
+
+DEFAULT_LOOKAHEAD_HOURS = 48
+DEFAULT_MAX_EVENTS = 100
+MAX_DESCRIPTION_CHARS = 500
+
+
+def _csv_set(name):
+    return {item.strip().lower() for item in os.environ.get(name, "").split(",") if item.strip()}
+
+
+def _stamp(value):
+    if value is None:
+        return None, False
+    if isinstance(value, datetime):
+        # A floating (tz-naive) DTSTART is legal iCalendar; treating it as UTC keeps
+        # the ordering sane instead of raising on the comparison.
+        aware = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
+        return aware.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), False
+    if isinstance(value, date):
+        return value.isoformat(), True
+    return None, False
+
+
+def _text(component, key, limit=None):
+    value = component.get(key)
+    if value is None:
+        return None
+    text = str(value).strip()
+    if not text:
+        return None
+    return text[:limit] if limit else text
+
+
+def _events_from(raw, calendar_name, ical_parser):
+    events = []
+    for component in ical_parser.from_ical(raw).walk("VEVENT"):
+        start_property = component.get("dtstart")
+        end_property = component.get("dtend")
+        start, all_day = _stamp(getattr(start_property, "dt", None))
+        end, _ = _stamp(getattr(end_property, "dt", None))
+        if start is None:
+            continue
+        events.append(
+            {
+                "source": "caldav",
+                "category": "calendar_event",
+                "calendar": calendar_name,
+                "uid": _text(component, "uid"),
+                "summary": _text(component, "summary") or "(no title)",
+                "start": start,
+                "end": end,
+                "all_day": all_day,
+                "location": _text(component, "location"),
+                "description": _text(component, "description", MAX_DESCRIPTION_CHARS),
+                "status": _text(component, "status"),
+                "recurring": component.get("rrule") is not None,
+            }
+        )
+    return events
+
+
+def _search(calendar, window_start, window_end):
+    try:
+        return calendar.search(start=window_start, end=window_end, event=True, expand=True)
+    except Exception:
+        # Some sabre/dav deployments reject an expand request outright. An
+        # unexpanded window is still worth having; the only cost is that a
+        # recurring series shows up as its master event.
+        LOG.warning("caldav: expanded search failed, retrying unexpanded", exc_info=True)
+        return calendar.search(start=window_start, end=window_end, event=True)
+
+
+def _calendar_name(calendar):
+    try:
+        return str(calendar.name or "")
+    except Exception:
+        # .name is a lazy PROPFIND; a calendar that will not name itself is still
+        # readable, and dropping it over a missing display name would be absurd.
+        return ""
+
+
+def fetch(lookback_hours):
+    url = os.environ.get("CALDAV_URL", "").strip()
+    username = os.environ.get("CALDAV_USERNAME", "").strip()
+    password = os.environ.get("CALDAV_PASSWORD", "")
+
+    if not (url and username and password):
+        LOG.warning("caldav: CALDAV_URL/CALDAV_USERNAME/CALDAV_PASSWORD not all set, skipping")
+        return []
+
+    lookahead_hours = float(os.environ.get("CALDAV_LOOKAHEAD_HOURS", DEFAULT_LOOKAHEAD_HOURS))
+    max_events = int(os.environ.get("CALDAV_MAX_EVENTS", DEFAULT_MAX_EVENTS))
+    wanted = _csv_set("CALDAV_CALENDARS")
+    verify = os.environ.get("CALDAV_VERIFY_TLS", "true").strip().lower() == "true"
+    if not verify:
+        LOG.warning("caldav: CALDAV_VERIFY_TLS is false, the app password is sent over an unverified session")
+
+    now = datetime.now(timezone.utc)
+    window_start = now - timedelta(hours=float(lookback_hours))
+    window_end = now + timedelta(hours=lookahead_hours)
+
+    events = []
+    try:
+        # Imported here, not at module scope, so a missing/broken optional
+        # dependency degrades this one source instead of the whole run. The
+        # absolute import resolves to the PyPI `caldav` package, not to this
+        # module, which is only ever reachable as `ingest.caldav`.
+        import caldav as caldav_lib
+        from icalendar import Calendar as ICalendar
+
+        with caldav_lib.DAVClient(
+            url=url, username=username, password=password, ssl_verify_cert=verify
+        ) as client:
+            for calendar in client.principal().calendars():
+                name = _calendar_name(calendar)
+                if wanted and name.lower() not in wanted:
+                    continue
+                try:
+                    for item in _search(calendar, window_start, window_end):
+                        events.extend(_events_from(item.data, name, ICalendar))
+                except Exception:
+                    LOG.warning("caldav: calendar %r could not be read, skipping", name, exc_info=True)
+    except Exception:
+        LOG.warning("caldav: ingestion failed, returning nothing", exc_info=True)
+        return []
+
+    events.sort(key=lambda event: event["start"])
+    if len(events) > max_events:
+        LOG.warning("caldav: %d events in the window, truncating to %d", len(events), max_events)
+        events = events[:max_events]
+
+    LOG.info("caldav: %d event(s) from -%sh to +%sh", len(events), lookback_hours, lookahead_hours)
+    return events
diff --git a/digest-engine/ingest/discord_ingest.py b/digest-engine/ingest/discord_ingest.py
new file mode 100644
index 0000000..a8bd2c0
--- /dev/null
+++ b/digest-engine/ingest/discord_ingest.py
@@ -0,0 +1,90 @@
+"""Discord ingestion via a discord.py bot.
+
+Scope is enforced by Discord itself, not by this code: a bot can only see guilds
+it has been invited to, and cannot read personal DMs at all. There is deliberately
+no selfbot path here — that is an explicit ToS violation with real ban risk
+(docs/project-plan.md Phase 12 step 4).
+
+The bot needs the Message Content privileged intent enabled at
+https://discord.com/developers/applications -> your app -> Bot -> Privileged
+Gateway Intents, otherwise every message body arrives empty.
+
+Bots have no read-state on Discord, so reading history acknowledges nothing.
+"""
+
+import asyncio
+import logging
+import os
+from datetime import datetime, timedelta, timezone
+
+LOG = logging.getLogger(__name__)
+
+MAX_BODY_CHARS = 2000
+
+
+async def _fetch_async(lookback_hours, token, max_per_channel, connect_timeout):
+    import discord
+
+    since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours)
+    messages = []
+
+    intents = discord.Intents.default()
+    intents.message_content = True
+    intents.guilds = True
+    client = discord.Client(intents=intents)
+
+    @client.event
+    async def on_ready():
+        try:
+            for guild in client.guilds:
+                for channel in guild.text_channels:
+                    permissions = channel.permissions_for(guild.me)
+                    if not (permissions.read_messages and permissions.read_message_history):
+                        continue
+                    try:
+                        async for message in channel.history(after=since, limit=max_per_channel):
+                            if not message.content:
+                                continue
+                            messages.append(
+                                {
+                                    "source": "discord",
+                                    "guild": guild.name,
+                                    "channel": channel.name,
+                                    "from": message.author.display_name,
+                                    "timestamp": message.created_at.isoformat(),
+                                    "body": message.content[:MAX_BODY_CHARS],
+                                }
+                            )
+                    except Exception:
+                        LOG.warning(
+                            "discord: could not read #%s in %s, skipping",
+                            channel.name,
+                            guild.name,
+                            exc_info=True,
+                        )
+        finally:
+            await client.close()
+
+    await asyncio.wait_for(client.start(token), timeout=connect_timeout)
+    return messages
+
+
+def fetch(lookback_hours):
+    token = os.environ.get("DISCORD_BOT_TOKEN", "").strip()
+    max_per_channel = int(os.environ.get("DISCORD_MAX_MESSAGES_PER_CHANNEL", "50"))
+    connect_timeout = float(os.environ.get("DISCORD_TIMEOUT", "120"))
+
+    if not token:
+        LOG.warning("discord: DISCORD_BOT_TOKEN not set, skipping")
+        return []
+
+    try:
+        messages = asyncio.run(
+            _fetch_async(lookback_hours, token, max_per_channel, connect_timeout)
+        )
+    except Exception:
+        LOG.warning("discord: ingestion failed, returning nothing", exc_info=True)
+        return []
+
+    LOG.info("discord: %d message(s) in the last %sh", len(messages), lookback_hours)
+    return messages
diff --git a/digest-engine/ingest/email_imap.py b/digest-engine/ingest/email_imap.py
new file mode 100644
index 0000000..ff6c02a
--- /dev/null
+++ b/digest-engine/ingest/email_imap.py
@@ -0,0 +1,106 @@
+"""IMAP mail ingestion.
+
+Auth is a plain password via EMAIL_PASSWORD, not OAuth2/XOAUTH2: for Gmail that
+means an App Password (enable 2FA, then generate one at myaccount.google.com ->
+Security -> App passwords); other providers have an equivalent app-specific
+password. OAuth2 would need a browser consent round-trip plus refresh-token
+storage, which is far too heavy for a single-user cron-style job — App Password
+is the documented, supported path for exactly this case.
+"""
+
+import email
+import email.utils
+import logging
+import os
+from datetime import datetime, timedelta, timezone
+from email.header import decode_header, make_header
+
+LOG = logging.getLogger(__name__)
+
+MAX_BODY_CHARS = 2000
+
+
+def _decode(value):
+    if not value:
+        return ""
+    try:
+        return str(make_header(decode_header(value)))
+    except Exception:
+        return str(value)
+
+
+def _body_text(message):
+    if message.is_multipart():
+        for part in message.walk():
+            if part.get_content_type() == "text/plain" and "attachment" not in str(
+                part.get("Content-Disposition", "")
+            ):
+                payload = part.get_payload(decode=True)
+                if payload:
+                    return payload.decode(part.get_content_charset() or "utf-8", "replace")
+        return ""
+    payload = message.get_payload(decode=True)
+    if not payload:
+        return ""
+    return payload.decode(message.get_content_charset() or "utf-8", "replace")
+
+
+def fetch(lookback_hours):
+    host = os.environ.get("EMAIL_IMAP_HOST", "").strip()
+    user = os.environ.get("EMAIL_USERNAME", "").strip()
+    password = os.environ.get("EMAIL_PASSWORD", "")
+    folder = os.environ.get("EMAIL_FOLDER", "INBOX")
+    port = int(os.environ.get("EMAIL_IMAP_PORT", "993"))
+    lookback_hours = float(os.environ.get("EMAIL_LOOKBACK_HOURS", lookback_hours))
+
+    if not (host and user and password):
+        LOG.warning("email: EMAIL_IMAP_HOST/EMAIL_USERNAME/EMAIL_PASSWORD not all set, skipping")
+        return []
+
+    since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours)
+    messages = []
+
+    try:
+        # Imported here, not at module scope, so a missing/broken optional
+        # dependency degrades this one source instead of the whole run.
+        from imapclient import IMAPClient
+
+        with IMAPClient(host, port=port, ssl=True) as client:
+            client.login(user, password)
+            # readonly=True is the whole point: it stops the server from setting
+            # \Seen on anything we FETCH. digest-engine never mutates the mailbox.
+            client.select_folder(folder, readonly=True)
+            uids = client.search(["SINCE", since.date()])
+            if not uids:
+                return []
+
+            for uid, data in client.fetch(uids, ["RFC822"]).items():
+                raw = data.get(b"RFC822")
+                if not raw:
+                    continue
+                try:
+                    parsed = email.message_from_bytes(raw)
+                    sent_at = email.utils.parsedate_to_datetime(parsed.get("Date"))
+                    if sent_at is not None and sent_at.tzinfo is None:
+                        sent_at = sent_at.replace(tzinfo=timezone.utc)
+                    if sent_at is not None and sent_at < since:
+                        continue
+                    messages.append(
+                        {
+                            "source": "email",
+                            "uid": int(uid),
+                            "from": _decode(parsed.get("From")),
+                            "to": _decode(parsed.get("To")),
+                            "subject": _decode(parsed.get("Subject")),
+                            "timestamp": sent_at.isoformat() if sent_at else None,
+                            "body": _body_text(parsed).strip()[:MAX_BODY_CHARS],
+                        }
+                    )
+                except Exception:
+                    LOG.warning("email: could not parse message uid=%s, skipping", uid, exc_info=True)
+    except Exception:
+        LOG.warning("email: ingestion failed, returning nothing", exc_info=True)
+        return []
+
+    LOG.info("email: %d message(s) in the last %sh", len(messages), lookback_hours)
+    return messages
diff --git a/digest-engine/ingest/financial.py b/digest-engine/ingest/financial.py
new file mode 100644
index 0000000..253cd72
--- /dev/null
+++ b/digest-engine/ingest/financial.py
@@ -0,0 +1,142 @@
+"""Financial ingestion — FRED (macro series) + Stooq (market/commodity prices).
+
+Stooq is used instead of Alpha Vantage because it needs no key at all and has no
+25-request/day cap, which a 4x/day digest would otherwise burn through
+(docs/project-plan.md Phase 12 step 4).
+
+Adding series/symbols is env-only, no code change:
+
+  FRED_SERIES  — comma-separated FRED series IDs, default "UNRATE" (US
+                 unemployment rate). Browse/search IDs at
+                 https://fred.stlouisfed.org — e.g. "UNRATE,CPIAUCSL,FEDFUNDS"
+                 for unemployment + CPI + the federal funds rate.
+  STOOQ_SYMBOLS — comma-separated Stooq symbols, default "^spx,^dax,cl.f".
+                 Stooq notation: "^spx"/"^dax"/"^ndq" for indices, bare tickers
+                 like "aapl.us" for US equities, and futures like "cl.f" (WTI
+                 crude), "gc.f" (gold), "ng.f" (natural gas).
+"""
+
+import csv
+import io
+import logging
+import os
+from datetime import datetime, timedelta, timezone
+
+import requests
+
+LOG = logging.getLogger(__name__)
+
+FRED_URL = "https://api.stlouisfed.org/fred/series/observations"
+STOOQ_URL = "https://stooq.com/q/d/l/"
+
+HTTP_TIMEOUT = 30
+STOOQ_HISTORY_DAYS = 45
+FRED_HISTORY_DAYS = 400
+
+
+def _csv_list(name, default):
+    raw = os.environ.get(name, default)
+    return [item.strip() for item in raw.split(",") if item.strip()]
+
+
+def _fetch_fred(series_ids, api_key):
+    observations = []
+    start = (datetime.now(timezone.utc) - timedelta(days=FRED_HISTORY_DAYS)).date()
+
+    for series_id in series_ids:
+        try:
+            response = requests.get(
+                FRED_URL,
+                params={
+                    "series_id": series_id,
+                    "api_key": api_key,
+                    "file_type": "json",
+                    "observation_start": start.isoformat(),
+                    "sort_order": "desc",
+                    "limit": 6,
+                },
+                timeout=HTTP_TIMEOUT,
+            )
+            response.raise_for_status()
+            points = [
+                {"date": item["date"], "value": item["value"]}
+                for item in response.json().get("observations", [])
+                if item.get("value") not in (None, ".")
+            ]
+            if not points:
+                LOG.warning("financial: FRED series %s returned no usable observations", series_id)
+                continue
+            observations.append(
+                {
+                    "source": "fred",
+                    "series_id": series_id,
+                    "latest_date": points[0]["date"],
+                    "latest_value": points[0]["value"],
+                    "previous_value": points[1]["value"] if len(points) > 1 else None,
+                    "recent": points,
+                }
+            )
+        except Exception:
+            LOG.warning("financial: FRED series %s failed, skipping", series_id, exc_info=True)
+
+    return observations
+
+
+def _fetch_stooq(symbols):
+    quotes = []
+    end = datetime.now(timezone.utc).date()
+    start = end - timedelta(days=STOOQ_HISTORY_DAYS)
+
+    for symbol in symbols:
+        try:
+            response = requests.get(
+                STOOQ_URL,
+                params={
+                    "s": symbol,
+                    "i": "d",
+                    "d1": start.strftime("%Y%m%d"),
+                    "d2": end.strftime("%Y%m%d"),
+                },
+                timeout=HTTP_TIMEOUT,
+            )
+            response.raise_for_status()
+            rows = [row for row in csv.DictReader(io.StringIO(response.text)) if row.get("Close")]
+            if not rows:
+                LOG.warning("financial: Stooq symbol %s returned no rows", symbol)
+                continue
+            latest = rows[-1]
+            previous = rows[-2] if len(rows) > 1 else None
+            first = rows[0]
+            quotes.append(
+                {
+                    "source": "stooq",
+                    "symbol": symbol,
+                    "date": latest["Date"],
+                    "close": float(latest["Close"]),
+                    "previous_close": float(previous["Close"]) if previous else None,
+                    "close_days_ago": float(first["Close"]),
+                    "window_days": STOOQ_HISTORY_DAYS,
+                }
+            )
+        except Exception:
+            LOG.warning("financial: Stooq symbol %s failed, skipping", symbol, exc_info=True)
+
+    return quotes
+
+
+def fetch(lookback_hours):
+    del lookback_hours  # macro series and daily bars move slower than the digest cadence
+
+    results = []
+
+    fred_key = os.environ.get("FRED_API_KEY", "").strip()
+    fred_series = _csv_list("FRED_SERIES", "UNRATE")
+    if fred_key:
+        results.extend(_fetch_fred(fred_series, fred_key))
+    else:
+        LOG.warning("financial: FRED_API_KEY not set, skipping macro series")
+
+    results.extend(_fetch_stooq(_csv_list("STOOQ_SYMBOLS", "^spx,^dax,cl.f")))
+
+    LOG.info("financial: %d indicator(s)", len(results))
+    return results
diff --git a/digest-engine/ingest/flight_traffic.py b/digest-engine/ingest/flight_traffic.py
new file mode 100644
index 0000000..b5c2d09
--- /dev/null
+++ b/digest-engine/ingest/flight_traffic.py
@@ -0,0 +1,227 @@
+"""Air-traffic ingestion — OpenSky Network state vectors over regions of interest.
+
+This is *additional analysis input* for the political section, tagged
+`"category": "flight_traffic"` in the same way the financial indicators are. It is
+not a section of its own — synth/prompts/political.md decides whether any of it
+means anything, and is told in as many words not to read ordinary civil aviation
+as military posture.
+
+Sourcing, all verified 2026-07-28:
+
+  OpenSky is the only one of the three obvious ADS-B aggregators with a
+  documented free REST API. FlightRadar24's ToS prohibit scraping and its
+  programmatic access is a paid product; ADS-B Exchange discontinued its
+  freemium RapidAPI tier on 2025-03-01 and now sells access from $10/month.
+  Neither is usable here without paying, so neither is offered as a fallback.
+
+  READ THIS BEFORE ENABLING: OpenSky's Terms of Use license the data for
+  non-profit research/education and personal use, and state that using the REST
+  API "in any operational capacity — including integration into a live product,
+  service, or automated system (even if only internal)" requires a prior written
+  agreement. A digest that runs on a timer is arguably exactly that. This is why
+  ENABLE_FLIGHT_TRAFFIC_INGEST is false by default: it is a decision for the
+  human running the box, not a default we can make for them. Attribution to
+  OpenSky is required wherever the data surfaces.
+
+  Rate limits are a daily credit budget per endpoint. Anonymous (by IP): 400
+  credits/day, current state vectors only, 10-second resolution. OAuth2 client
+  credentials: 4,000/day, 5-second resolution, up to 1h of history. Active
+  feeders: 8,000/day. A /states/all call costs 1 credit for a bounding box of
+  <=25 sq degrees, 2 for 25-100, 3 for 100-400, and 4 for a global dump. The
+  default region list below is deliberately a handful of bounded boxes rather
+  than one global dump: ~12 credits per run, ~48/day at the 4x/day cadence,
+  which fits inside even the anonymous budget with room to spare.
+
+  Basic auth was removed on 2026-03-18; OAuth2 client credentials is the only
+  authenticated flow that still works.
+
+The military-aircraft signal here is a callsign-prefix heuristic and nothing
+more. It catches transport and tanker traffic flying under published national
+callsigns (REACH, ASCOT, CANFORCE) because those aircraft have no reason to hide.
+It will not catch anything that has switched its transponder off, which is most
+of what would actually matter. Absence of matches is therefore not evidence of
+absence, and the prompt is told so.
+
+Configuration is env-only:
+
+  FLIGHT_REGIONS — semicolon-separated `Name:lamin,lomin,lamax,lomax` boxes in
+                   decimal degrees. The shipped default is a placeholder list of
+                   currently-tense regions and needs the user's review; conflict
+                   zones move and this file does not update itself.
+  FLIGHT_MILITARY_CALLSIGN_PREFIXES — comma-separated callsign prefixes counted
+                   as military.
+"""
+
+import logging
+import os
+from datetime import datetime, timezone
+
+import requests
+
+LOG = logging.getLogger(__name__)
+
+STATES_URL = "https://opensky-network.org/api/states/all"
+TOKEN_URL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"
+
+HTTP_TIMEOUT = 45
+
+DEFAULT_REGIONS = (
+    "Eastern Mediterranean / Levant:30,32,37,37;"
+    "Black Sea / Ukraine:44,29,53,41;"
+    "Persian Gulf / Strait of Hormuz:24,50,30,60;"
+    "Red Sea / Bab el-Mandeb:12,38,20,45;"
+    "Taiwan Strait:21,117,27,124;"
+    "Baltic / Kaliningrad:53,17,60,28"
+)
+
+DEFAULT_MILITARY_PREFIXES = "RCH,CNV,RRR,CFC,GAF,IAM,FAF,BAF,NAF,PLF,HAF,NATO,SVF"
+
+# /states/all returns bare arrays, not objects, so the offsets have to be named here.
+_ICAO24 = 0
+_CALLSIGN = 1
+_ORIGIN_COUNTRY = 2
+_LONGITUDE = 5
+_LATITUDE = 6
+_BARO_ALTITUDE = 7
+_ON_GROUND = 8
+_VELOCITY = 9
+_GEO_ALTITUDE = 13
+
+
+def _csv_list(name, default):
+    raw = os.environ.get(name, default)
+    return [item.strip() for item in raw.split(",") if item.strip()]
+
+
+def _parse_regions(raw):
+    regions = []
+    for chunk in raw.split(";"):
+        chunk = chunk.strip()
+        if not chunk:
+            continue
+        try:
+            name, box = chunk.rsplit(":", 1)
+            lamin, lomin, lamax, lomax = (float(part) for part in box.split(","))
+            regions.append(
+                {
+                    "name": name.strip(),
+                    "lamin": lamin,
+                    "lomin": lomin,
+                    "lamax": lamax,
+                    "lomax": lomax,
+                }
+            )
+        except Exception:
+            LOG.warning("flight_traffic: could not parse region %r, skipping", chunk, exc_info=True)
+    return regions
+
+
+def _access_token(client_id, client_secret):
+    response = requests.post(
+        TOKEN_URL,
+        data={
+            "grant_type": "client_credentials",
+            "client_id": client_id,
+            "client_secret": client_secret,
+        },
+        timeout=HTTP_TIMEOUT,
+    )
+    response.raise_for_status()
+    return response.json()["access_token"]
+
+
+def _is_military(callsign, prefixes):
+    return any(callsign.startswith(prefix) for prefix in prefixes)
+
+
+def _summarise(region, states, prefixes, max_military):
+    military = []
+    military_total = 0
+    on_ground = 0
+
+    for state in states:
+        if state[_ON_GROUND]:
+            on_ground += 1
+        callsign = (state[_CALLSIGN] or "").strip().upper()
+        if not callsign or not _is_military(callsign, prefixes):
+            continue
+        military_total += 1
+        if len(military) >= max_military:
+            continue
+        military.append(
+            {
+                "callsign": callsign,
+                "icao24": state[_ICAO24],
+                "origin_country": state[_ORIGIN_COUNTRY],
+                "lat": state[_LATITUDE],
+                "lon": state[_LONGITUDE],
+                "altitude_m": state[_GEO_ALTITUDE] if state[_GEO_ALTITUDE] is not None else state[_BARO_ALTITUDE],
+                "velocity_ms": state[_VELOCITY],
+                "on_ground": state[_ON_GROUND],
+            }
+        )
+
+    return {
+        "source": "opensky",
+        "category": "flight_traffic",
+        "region": region["name"],
+        "bbox": [region["lamin"], region["lomin"], region["lamax"], region["lomax"]],
+        "observed_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
+        "aircraft_total": len(states),
+        "aircraft_on_ground": on_ground,
+        "military_callsign_matches": military,
+        "military_callsign_match_count": military_total,
+        "attribution": "Data: The OpenSky Network, https://opensky-network.org",
+        "caveat": (
+            "Instantaneous snapshot of ADS-B-visible aircraft only. Military callsign "
+            "matching is a prefix heuristic; aircraft with transponders off are invisible."
+        ),
+    }
+
+
+def fetch(lookback_hours):
+    del lookback_hours  # the free tier serves the current state vector only; there is no window to query
+
+    regions = _parse_regions(os.environ.get("FLIGHT_REGIONS", DEFAULT_REGIONS))
+    if not regions:
+        LOG.warning("flight_traffic: FLIGHT_REGIONS parsed to nothing, skipping")
+        return []
+
+    prefixes = [item.upper() for item in _csv_list("FLIGHT_MILITARY_CALLSIGN_PREFIXES", DEFAULT_MILITARY_PREFIXES)]
+    max_military = int(os.environ.get("FLIGHT_MAX_MILITARY_PER_REGION", "20"))
+
+    headers = {}
+    client_id = os.environ.get("OPENSKY_CLIENT_ID", "").strip()
+    client_secret = os.environ.get("OPENSKY_CLIENT_SECRET", "").strip()
+    if client_id and client_secret:
+        try:
+            headers["Authorization"] = "Bearer " + _access_token(client_id, client_secret)
+        except Exception:
+            # Anonymous access still works at a tenth of the credit budget, which is
+            # enough for the default region list, so a bad token is not fatal.
+            LOG.warning("flight_traffic: OpenSky token request failed, falling back to anonymous", exc_info=True)
+    else:
+        LOG.info("flight_traffic: no OPENSKY_CLIENT_ID/SECRET, using anonymous access (400 credits/day)")
+
+    results = []
+    for region in regions:
+        try:
+            response = requests.get(
+                STATES_URL,
+                params={
+                    "lamin": region["lamin"],
+                    "lomin": region["lomin"],
+                    "lamax": region["lamax"],
+                    "lomax": region["lomax"],
+                },
+                headers=headers,
+                timeout=HTTP_TIMEOUT,
+            )
+            response.raise_for_status()
+            states = response.json().get("states") or []
+            results.append(_summarise(region, states, prefixes, max_military))
+        except Exception:
+            LOG.warning("flight_traffic: region %s failed, skipping", region["name"], exc_info=True)
+
+    LOG.info("flight_traffic: %d of %d region(s) sampled", len(results), len(regions))
+    return results
diff --git a/digest-engine/ingest/grocy.py b/digest-engine/ingest/grocy.py
new file mode 100644
index 0000000..344c2b6
--- /dev/null
+++ b/digest-engine/ingest/grocy.py
@@ -0,0 +1,289 @@
+"""Grocy ingestion — expiring stock, missing stock, chores and battery charges.
+
+READ-ONLY INVARIANT: this module issues GET requests and nothing else, ever.
+Grocy's API is a full read/write API and the write endpoints are trivially
+reachable with the same key — `POST /api/stock/products/{id}/{add,consume,
+transfer,inventory,open}`, `POST /api/chores/{id}/execute`, `POST
+/api/batteries/{id}/charge`, and the whole `POST /api/stock/shoppinglist/*`
+family (`add-missing-products`, `add-overdue-products`, `add-expired-products`,
+`add-product`, `remove-product`, `clear`) plus `POST /api/recipes/{id}/
+add-not-fulfilled-products-to-shoppinglist`. None of them are used here and none
+of them may ever be, per docs/project-plan.md Phase 12 step 8. That applies in
+particular to the evening recipe/shopping-list feature in
+synth/prompts/household.md: the suggested shopping list is rendered in the digest
+for a human to act on, and is NEVER pushed into Grocy's own shopping list.
+
+Sourcing, verified against grocy.openapi.json on grocy/grocy master 2026-07-28:
+
+  `GET /api/stock/volatile?due_soon_days=N` is the direct answer to "what is
+  about to go off". It returns one object with four arrays — `due_products`
+  (within N days), `overdue_products` (past a best-before date),
+  `expired_products` (past a hard expiration date) and `missing_products`
+  (below min_stock_amount). Reconstructing that from `/api/objects/products`
+  plus stock entries is unnecessary. Note the naming: Grocy renamed
+  `expiring_products` -> `due_products` in v3.0.0, so older third-party examples
+  showing `expiring_products` are wrong against a current install.
+
+  The first three arrays are `CurrentStockResponse` objects: `product_id`,
+  `amount`, `best_before_date` (documented as "the next due date for this
+  product", not necessarily a best-before) and a nested `product`.
+  `missing_products` is a different, smaller shape: `id`, `name`,
+  `amount_missing`, `is_partly_in_stock`.
+
+  `GET /api/stock` gives everything currently in stock. It is here only so the
+  evening shopping-list suggestion can tell "we already have this" from "buy
+  this" — see the note on GROCY_MAX_STOCK_ITEMS below.
+
+  `GET /api/chores` and `GET /api/batteries` return next-execution/next-charge
+  estimates. Both use `2999-12-31 23:59:59` as the "no schedule" sentinel
+  (chores with period_type `manually`, batteries with no charge_interval_days),
+  which is filtered out here rather than reported as a due date in the year 2999.
+  `/api/batteries` returns only `battery_id`, so names come from
+  `/api/objects/batteries`.
+
+  Auth is a per-user API key in a `GROCY-API-KEY` header (the OpenAPI spec's only
+  security scheme). Generate one in Grocy at Settings -> Manage API keys
+  (`/manageapikeys`). There is no OAuth and no scoping: a Grocy API key carries
+  that user's full read *and* write rights, so the read-only guarantee above
+  comes from this code, exactly as it does for the OPNsense key.
+
+Reachability: the deployed Grocy is `lscr.io/linuxserver/grocy` as service
+`grocy` on port 80 internally (published as 9283), on the same default compose
+network as digest-engine — so the default GROCY_URL is the container name, and
+no host networking or extra compose plumbing is needed.
+"""
+
+import logging
+import os
+from datetime import date, datetime
+
+import requests
+
+LOG = logging.getLogger(__name__)
+
+DEFAULT_URL = "http://grocy"
+HTTP_TIMEOUT = 30
+
+DEFAULT_DUE_SOON_DAYS = 5
+DEFAULT_TASK_HORIZON_DAYS = 7
+DEFAULT_MAX_STOCK_ITEMS = 200
+
+# Grocy's "this never happens" sentinel date, used by chores with a manual period
+# and by batteries with no charge interval.
+NEVER_YEAR = 2999
+
+
+def _get(session, base_url, path, params=None, default=None):
+    try:
+        response = session.get(base_url + path, params=params, timeout=HTTP_TIMEOUT)
+        response.raise_for_status()
+        return response.json()
+    except Exception:
+        LOG.warning("grocy: GET %s failed, continuing without it", path, exc_info=True)
+        return default
+
+
+def _num(value):
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _parse_when(raw):
+    text = str(raw or "").strip()
+    if not text:
+        return None
+    try:
+        parsed = datetime.fromisoformat(text)
+    except ValueError:
+        return None
+    return None if parsed.year >= NEVER_YEAR else parsed
+
+
+def _index(rows):
+    return {str(row.get("id")): row for row in rows or [] if row.get("id") is not None}
+
+
+def _unit_label(units, qu_id, amount):
+    unit = units.get(str(qu_id))
+    if not unit:
+        return None
+    if amount == 1:
+        return unit.get("name")
+    return unit.get("name_plural") or unit.get("name")
+
+
+def _product_of(row, products):
+    nested = row.get("product") or {}
+    stored = products.get(str(row.get("product_id") or nested.get("id"))) or {}
+    # Merged rather than either/or: /api/stock nests the product, /api/objects/
+    # shopping_list only carries a product_id, and either request may have failed.
+    return {**nested, **stored} if stored else nested
+
+
+def _stock_entry(row, products, units, category, status, today):
+    product = _product_of(row, products)
+    amount = _num(row.get("amount"))
+    due = _parse_when(row.get("best_before_date"))
+    return {
+        "source": "grocy",
+        "category": category,
+        "status": status,
+        "product": product.get("name") or f"product #{row.get('product_id')}",
+        "amount": amount,
+        "unit": _unit_label(units, product.get("qu_id_stock"), amount),
+        "due_date": due.date().isoformat() if due else None,
+        "days_until_due": (due.date() - today).days if due else None,
+    }
+
+
+def _volatile_entries(volatile, products, units, today):
+    entries = []
+
+    for key, status in (
+        ("due_products", "due_soon"),
+        ("overdue_products", "overdue"),
+        ("expired_products", "expired"),
+    ):
+        for row in volatile.get(key) or []:
+            entries.append(_stock_entry(row, products, units, "expiring_stock", status, today))
+
+    for row in volatile.get("missing_products") or []:
+        entries.append(
+            {
+                "source": "grocy",
+                "category": "missing_stock",
+                "product": row.get("name") or f"product #{row.get('id')}",
+                "amount_missing": _num(row.get("amount_missing")),
+                "partly_in_stock": bool(_num(row.get("is_partly_in_stock"))),
+            }
+        )
+
+    return entries
+
+
+def _stock_entries(stock, products, units, today, limit):
+    rows = sorted(stock or [], key=lambda row: str((_product_of(row, products)).get("name") or ""))
+    if len(rows) > limit:
+        LOG.warning(
+            "grocy: %d products in stock, only the first %d are reported — raise "
+            "GROCY_MAX_STOCK_ITEMS or the evening shopping list may suggest buying "
+            "something you already have",
+            len(rows),
+            limit,
+        )
+        rows = rows[:limit]
+    return [_stock_entry(row, products, units, "in_stock", "in_stock", today) for row in rows]
+
+
+def _chore_entries(chores, today, horizon_days):
+    entries = []
+    for row in chores or []:
+        due = _parse_when(row.get("next_estimated_execution_time"))
+        if due is None:
+            continue
+        days = (due.date() - today).days
+        if days > horizon_days:
+            continue
+        assigned = row.get("next_execution_assigned_user") or {}
+        entries.append(
+            {
+                "source": "grocy",
+                "category": "chore",
+                "name": row.get("chore_name") or f"chore #{row.get('chore_id')}",
+                "due_at": due.isoformat(),
+                "days_until_due": days,
+                "last_done": row.get("last_tracked_time"),
+                "assigned_to": assigned.get("display_name") or assigned.get("username"),
+            }
+        )
+    return entries
+
+
+def _battery_entries(batteries, meta, today, horizon_days):
+    entries = []
+    for row in batteries or []:
+        due = _parse_when(row.get("next_estimated_charge_time"))
+        if due is None:
+            continue
+        days = (due.date() - today).days
+        if days > horizon_days:
+            continue
+        battery = meta.get(str(row.get("battery_id"))) or {}
+        entries.append(
+            {
+                "source": "grocy",
+                "category": "battery",
+                "name": battery.get("name") or f"battery #{row.get('battery_id')}",
+                "used_in": battery.get("used_in"),
+                "due_at": due.isoformat(),
+                "days_until_due": days,
+                "last_charged": row.get("last_tracked_time"),
+            }
+        )
+    return entries
+
+
+def _shopping_entries(items, products, units):
+    entries = []
+    for row in items or []:
+        product = _product_of(row, products)
+        amount = _num(row.get("amount"))
+        entries.append(
+            {
+                "source": "grocy",
+                "category": "shopping_list",
+                "product": product.get("name") or (row.get("note") or "").strip() or "unnamed item",
+                "amount": amount,
+                "unit": _unit_label(units, product.get("qu_id_stock"), amount),
+                "note": (row.get("note") or "").strip() or None,
+            }
+        )
+    return entries
+
+
+def fetch(lookback_hours):
+    del lookback_hours  # Grocy state is a snapshot of right now, not a time window
+
+    base_url = os.environ.get("GROCY_URL", DEFAULT_URL).strip().rstrip("/")
+    api_key = os.environ.get("GROCY_API_KEY", "").strip()
+    if not api_key:
+        LOG.warning("grocy: GROCY_API_KEY not set, skipping (create one at Grocy -> Manage API keys)")
+        return []
+
+    due_soon_days = int(os.environ.get("GROCY_DUE_SOON_DAYS", DEFAULT_DUE_SOON_DAYS))
+    horizon_days = int(os.environ.get("GROCY_TASK_HORIZON_DAYS", DEFAULT_TASK_HORIZON_DAYS))
+    max_stock_items = int(os.environ.get("GROCY_MAX_STOCK_ITEMS", DEFAULT_MAX_STOCK_ITEMS))
+
+    session = requests.Session()
+    session.headers.update({"GROCY-API-KEY": api_key, "Accept": "application/json"})
+
+    volatile = _get(session, base_url, "/api/stock/volatile", {"due_soon_days": due_soon_days})
+    if not isinstance(volatile, dict):
+        # Bail on the first call rather than letting seven more requests each burn
+        # their own connect timeout: if this one failed, Grocy is unreachable or the
+        # key is wrong, and the rest will fail identically.
+        LOG.warning("grocy: the stock query failed, skipping the source entirely")
+        return []
+
+    products = _index(_get(session, base_url, "/api/objects/products", default=[]))
+    units = _index(_get(session, base_url, "/api/objects/quantity_units", default=[]))
+    today = date.today()
+
+    entries = _volatile_entries(volatile, products, units, today)
+    entries += _stock_entries(_get(session, base_url, "/api/stock", default=[]), products, units, today, max_stock_items)
+    entries += _chore_entries(_get(session, base_url, "/api/chores", default=[]), today, horizon_days)
+    entries += _battery_entries(
+        _get(session, base_url, "/api/batteries", default=[]),
+        _index(_get(session, base_url, "/api/objects/batteries", default=[])),
+        today,
+        horizon_days,
+    )
+    entries += _shopping_entries(
+        _get(session, base_url, "/api/objects/shopping_list", default=[]), products, units
+    )
+
+    expiring = sum(1 for entry in entries if entry["category"] == "expiring_stock")
+    LOG.info("grocy: %d entr(ies), %d of them expiring stock", len(entries), expiring)
+    return entries
diff --git a/digest-engine/ingest/naval_traffic.py b/digest-engine/ingest/naval_traffic.py
new file mode 100644
index 0000000..10104a4
--- /dev/null
+++ b/digest-engine/ingest/naval_traffic.py
@@ -0,0 +1,248 @@
+"""Naval/AIS ingestion — aisstream.io, sampled for a few seconds per run.
+
+Additional analysis input for the political section, tagged
+`"category": "naval_traffic"`. Not a section of its own.
+
+What this can and cannot do, stated plainly because it is easy to get wrong:
+
+AIS is a collision-avoidance transponder, not a surveillance system. Warships,
+auxiliaries and anything else with a reason to be discreet sail with AIS off or
+spoofed as a matter of routine, and the AIS "military ops" ship-type code is
+self-declared and almost never set by an actual combatant. **This module cannot
+detect a naval concentration and must never be read as if it could.** What it can
+measure is merchant traffic, and merchant traffic *withdrawing* from a chokepoint
+— the Red Sea transit collapse being the obvious case — is a real signal that
+pairs with the freight-cost, insurance and crude-price material the financial and
+news sources already supply. That, and only that, is why this exists.
+
+Sourcing, all verified 2026-07-28:
+
+  aisstream.io is the only genuinely free real-time AIS source found. A free
+  account (GitHub sign-in) yields an API key; the key is the sole credential and
+  is sent inside the subscription payload, not an HTTP header. It is WebSocket-
+  only — there is no REST endpoint — which is why this module samples a short
+  window rather than making a request. It is self-described as beta with no
+  uptime SLA, so treat a run that returns nothing as normal rather than broken.
+  A global subscription can push ~300 messages/second, so the bounding boxes
+  below are not optional.
+
+  AISHub remains contribute-to-access: you must stream raw NMEA from your own
+  AIS receiver to their UDP endpoint and meet coverage/uptime thresholds before
+  you get API credentials. That needs physical hardware this project does not
+  have, so it is not an option here.
+
+  MarineTraffic, VesselFinder and Spire are commercial/paid. Not integrated.
+
+Configuration is env-only:
+
+  AISSTREAM_API_KEY — required; free, from https://aisstream.io after sign-in.
+  NAVAL_REGIONS     — semicolon-separated `Name:lamin,lomin,lamax,lomax` boxes,
+                      same format as FLIGHT_REGIONS. Defaults to the maritime
+                      chokepoints, which is where the signal is.
+  NAVAL_SAMPLE_SECONDS — how long to hold the socket open. The default is
+                      deliberately short: this is a traffic-density sample, not
+                      a census, and a oneshot digest should not sit on a socket.
+"""
+
+import json
+import logging
+import os
+import time
+from datetime import datetime, timezone
+
+LOG = logging.getLogger(__name__)
+
+STREAM_URL = "wss://stream.aisstream.io/v0/stream"
+
+DEFAULT_REGIONS = (
+    "Red Sea / Bab el-Mandeb:12,38,20,45;"
+    "Strait of Hormuz:24,54,28,58;"
+    "Black Sea:41,27,47,42;"
+    "Taiwan Strait:21,117,27,124;"
+    "Suez Canal approaches:29,32,32,34"
+)
+
+# AIS ship-type code ranges, ITU-R M.1371. Coarse on purpose — the useful question
+# is "what kind of trade is moving through here", not the exact hull category.
+_TYPE_BUCKETS = (
+    (80, 89, "tanker"),
+    (70, 79, "cargo"),
+    (60, 69, "passenger"),
+    (40, 49, "high_speed_craft"),
+    (30, 30, "fishing"),
+    (35, 35, "self_declared_military_ops"),
+    (55, 55, "self_declared_law_enforcement"),
+)
+
+
+def _parse_regions(raw):
+    regions = []
+    for chunk in raw.split(";"):
+        chunk = chunk.strip()
+        if not chunk:
+            continue
+        try:
+            name, box = chunk.rsplit(":", 1)
+            lamin, lomin, lamax, lomax = (float(part) for part in box.split(","))
+            regions.append(
+                {
+                    "name": name.strip(),
+                    "lamin": lamin,
+                    "lomin": lomin,
+                    "lamax": lamax,
+                    "lomax": lomax,
+                }
+            )
+        except Exception:
+            LOG.warning("naval_traffic: could not parse region %r, skipping", chunk, exc_info=True)
+    return regions
+
+
+def _bucket(type_code):
+    for low, high, label in _TYPE_BUCKETS:
+        if low <= type_code <= high:
+            return label
+    return "other"
+
+
+def _region_for(regions, lat, lon):
+    for region in regions:
+        if region["lamin"] <= lat <= region["lamax"] and region["lomin"] <= lon <= region["lomax"]:
+            return region["name"]
+    return None
+
+
+def _sample(socket, regions, deadline):
+    seen = {region["name"]: set() for region in regions}
+    types = {region["name"]: {} for region in regions}
+    names = {region["name"]: {} for region in regions}
+    received = 0
+
+    while time.monotonic() < deadline:
+        try:
+            raw = socket.recv()
+        except Exception:
+            # A recv timeout inside the sampling window is ordinary on a quiet box;
+            # anything worse is caught by the caller.
+            break
+        if not raw:
+            continue
+        try:
+            message = json.loads(raw)
+        except ValueError:
+            continue
+
+        received += 1
+        metadata = message.get("MetaData") or {}
+        lat = metadata.get("latitude")
+        lon = metadata.get("longitude")
+        mmsi = metadata.get("MMSI")
+        if lat is None or lon is None or mmsi is None:
+            continue
+
+        region_name = _region_for(regions, lat, lon)
+        if region_name is None:
+            continue
+
+        seen[region_name].add(mmsi)
+
+        static = (message.get("Message") or {}).get("ShipStaticData")
+        if static and isinstance(static.get("Type"), int):
+            label = _bucket(static["Type"])
+            types[region_name][label] = types[region_name].get(label, 0) + 1
+            ship_name = (static.get("Name") or metadata.get("ShipName") or "").strip()
+            if ship_name and label in ("self_declared_military_ops", "self_declared_law_enforcement"):
+                names[region_name][ship_name] = label
+
+    return seen, types, names, received
+
+
+def fetch(lookback_hours):
+    del lookback_hours  # AIS arrives as a live stream; this is a sample of now, not of a window
+
+    api_key = os.environ.get("AISSTREAM_API_KEY", "").strip()
+    if not api_key:
+        LOG.warning("naval_traffic: AISSTREAM_API_KEY not set, skipping")
+        return []
+
+    regions = _parse_regions(os.environ.get("NAVAL_REGIONS", DEFAULT_REGIONS))
+    if not regions:
+        LOG.warning("naval_traffic: NAVAL_REGIONS parsed to nothing, skipping")
+        return []
+
+    sample_seconds = float(os.environ.get("NAVAL_SAMPLE_SECONDS", "20"))
+
+    # Imported here, not at module scope, so a missing/broken optional dependency
+    # degrades this one source instead of the whole run.
+    try:
+        import websocket
+    except ImportError:
+        LOG.warning("naval_traffic: websocket-client is not installed, skipping", exc_info=True)
+        return []
+
+    socket = None
+    try:
+        socket = websocket.create_connection(STREAM_URL, timeout=sample_seconds)
+        socket.send(
+            json.dumps(
+                {
+                    "APIKey": api_key,
+                    # aisstream orders corners [lat, lon], not GeoJSON's [lon, lat].
+                    "BoundingBoxes": [
+                        [[region["lamin"], region["lomin"]], [region["lamax"], region["lomax"]]]
+                        for region in regions
+                    ],
+                    "FilterMessageTypes": ["PositionReport", "ShipStaticData"],
+                }
+            )
+        )
+        seen, types, names, received = _sample(socket, regions, time.monotonic() + sample_seconds)
+    except Exception:
+        LOG.warning("naval_traffic: aisstream sample failed, skipping", exc_info=True)
+        return []
+    finally:
+        if socket is not None:
+            try:
+                socket.close()
+            except Exception:
+                LOG.debug("naval_traffic: socket close failed", exc_info=True)
+
+    # aisstream rejects a bad key by closing the socket without an error message, so
+    # a sample that saw literally nothing is indistinguishable from a rejected
+    # subscription. Contributing nothing is honest; contributing "zero vessels in
+    # every chokepoint" would be a fabricated finding the prompt would have to trust.
+    if not received:
+        LOG.warning("naval_traffic: no AIS messages in the sample window (bad key, or no traffic), skipping")
+        return []
+
+    observed_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
+    results = []
+    for region in regions:
+        name = region["name"]
+        results.append(
+            {
+                "source": "aisstream",
+                "category": "naval_traffic",
+                "region": name,
+                "bbox": [region["lamin"], region["lomin"], region["lamax"], region["lomax"]],
+                "observed_at": observed_at,
+                "sample_seconds": sample_seconds,
+                "distinct_vessels": len(seen[name]),
+                "vessel_types": types[name],
+                "self_declared_state_vessels": names[name],
+                "attribution": "AIS data via aisstream.io",
+                "caveat": (
+                    "Civil AIS only, sampled for a few seconds. Warships routinely sail with "
+                    "AIS off, so this cannot show naval force posture; it shows whether "
+                    "merchant traffic is still using the chokepoint."
+                ),
+            }
+        )
+
+    LOG.info(
+        "naval_traffic: %d region(s), %d distinct vessel(s) in a %ss sample",
+        len(results),
+        sum(len(value) for value in seen.values()),
+        sample_seconds,
+    )
+    return results
diff --git a/digest-engine/ingest/news_rss.py b/digest-engine/ingest/news_rss.py
new file mode 100644
index 0000000..fae89c6
--- /dev/null
+++ b/digest-engine/ingest/news_rss.py
@@ -0,0 +1,108 @@
+"""News ingestion — feedparser over the curated OPML feed list.
+
+The feed list is user-editable at digest-engine/feeds/curated-feeds.opml; each
+`` with an `xmlUrl` attribute is fetched. marxist.com is tagged in the
+OPML with `category="theory"` and that category is carried through onto every
+entry, because the political prompt treats it as the analytical basis rather than
+as one more headline source.
+"""
+
+import calendar
+import html
+import logging
+import os
+import re
+import xml.etree.ElementTree as ElementTree
+from datetime import datetime, timedelta, timezone
+
+LOG = logging.getLogger(__name__)
+
+DEFAULT_OPML_PATH = "/app/feeds/curated-feeds.opml"
+MAX_SUMMARY_CHARS = 1200
+
+_TAG_RE = re.compile(r"<[^>]+>")
+
+
+def _plain_text(markup):
+    return html.unescape(_TAG_RE.sub(" ", markup or "")).strip()
+
+
+def _read_opml(path):
+    feeds = []
+    tree = ElementTree.parse(path)
+    for outline in tree.iter("outline"):
+        url = outline.get("xmlUrl")
+        if not url:
+            continue
+        feeds.append(
+            {
+                "url": url,
+                "title": outline.get("title") or outline.get("text") or url,
+                "category": outline.get("category") or "news",
+            }
+        )
+    return feeds
+
+
+def _entry_time(entry):
+    parsed = entry.get("published_parsed") or entry.get("updated_parsed")
+    if not parsed:
+        return None
+    return datetime.fromtimestamp(calendar.timegm(parsed), tz=timezone.utc)
+
+
+def fetch(lookback_hours):
+    opml_path = os.environ.get("NEWS_OPML_PATH", DEFAULT_OPML_PATH)
+    max_per_feed = int(os.environ.get("NEWS_MAX_ENTRIES_PER_FEED", "15"))
+
+    # Imported here, not at module scope, so a missing/broken optional dependency
+    # degrades this one source instead of the whole run.
+    try:
+        import feedparser
+    except ImportError:
+        LOG.warning("news: feedparser is not installed, skipping", exc_info=True)
+        return []
+
+    try:
+        feeds = _read_opml(opml_path)
+    except Exception:
+        LOG.warning("news: could not read OPML at %s, returning nothing", opml_path, exc_info=True)
+        return []
+
+    if not feeds:
+        LOG.warning("news: no feeds with an xmlUrl in %s, skipping", opml_path)
+        return []
+
+    since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours)
+    entries = []
+
+    for feed in feeds:
+        try:
+            parsed = feedparser.parse(feed["url"])
+            if parsed.get("bozo") and not parsed.get("entries"):
+                LOG.warning("news: feed %s did not parse, skipping", feed["url"])
+                continue
+            kept = 0
+            for entry in parsed.entries:
+                if kept >= max_per_feed:
+                    break
+                published = _entry_time(entry)
+                if published and published < since:
+                    continue
+                entries.append(
+                    {
+                        "source": "news",
+                        "feed": feed["title"],
+                        "category": feed["category"],
+                        "title": entry.get("title", "").strip(),
+                        "link": entry.get("link", ""),
+                        "timestamp": published.isoformat() if published else None,
+                        "summary": _plain_text(entry.get("summary"))[:MAX_SUMMARY_CHARS],
+                    }
+                )
+                kept += 1
+        except Exception:
+            LOG.warning("news: feed %s failed, skipping", feed["url"], exc_info=True)
+
+    LOG.info("news: %d entr(ies) from %d feed(s) in the last %sh", len(entries), len(feeds), lookback_hours)
+    return entries
diff --git a/digest-engine/ingest/opnsense_ids.py b/digest-engine/ingest/opnsense_ids.py
new file mode 100644
index 0000000..c056855
--- /dev/null
+++ b/digest-engine/ingest/opnsense_ids.py
@@ -0,0 +1,300 @@
+"""OPNsense IDS ingestion — a Suricata alert summary for the preceding digest window.
+
+READ-ONLY INVARIANT: this module touches exactly two OPNsense API endpoints —
+`GET /api/ids/service/status` and `POST /api/ids/service/query_alerts` — and no
+others. `query_alerts` is a POST because OPNsense routes all filtered queries
+that way, not because it changes anything: it runs
+`/usr/local/opnsense/scripts/suricata/queryAlertLog.py`, which opens
+`/var/log/suricata/eve.json` and reads it backwards. Nothing here starts, stops,
+reconfigures, reloads or clears anything on the firewall, and nothing here may
+ever be changed to, per docs/project-plan.md Phase 12 step 8. The endpoints that
+*would* mutate state (`/ids/service/{start,stop,restart,reconfigure,reload_rules,
+update_rules,drop_alert_log}`) are named here only so it is obvious they are
+deliberately not used.
+
+Sourcing, all verified against OPNsense source 2026-07-28:
+
+  Suricata is **core**, not a plugin. The premise that this needs `os-suricata`
+  installed is wrong: the IDS module ships in opnsense/core
+  (src/opnsense/mvc/app/controllers/OPNsense/IDS/) and the GUI lives at
+  Services -> Intrusion Detection on a stock install. The only IDS-related
+  plugins in opnsense/plugins are ruleset *content* packages
+  (os-intrusion-detection-content-et-pro and friends); ET Open and the abuse.ch
+  lists need no plugin at all.
+
+  Alerts are queryable over the API. `POST /api/ids/service/query_alerts` takes
+  `rowCount`, `current` (1-based page), `searchPhrase` and `fileid`, and returns
+  `{"rows": [...], "total": n, "rowCount": n, "current": n}`. SSH or file access
+  to /var/log/suricata/ is NOT required, which is why this is a pull over HTTPS
+  like every other ingestion module here.
+
+  There is **no server-side time filter**. `searchPhrase` is a substring match
+  against the signature, action, source IP and destination IP only. Rows come
+  back newest-first (the backend uses a reverse log reader), so the digest window
+  is applied client-side by paging until a row falls out of it. That is also why
+  `max_alerts_scanned` exists: a noisy WAN interface can produce more alerts in
+  six hours than is sane to page through or to put in an LLM context.
+
+  **Severity is not available.** queryAlertLog.py flattens each eve.json record
+  down to `alert` (the signature text), `alert_sid` and `alert_action` before it
+  returns, discarding `alert.severity` and `alert.category`. `get_alert_info`
+  goes through the same flattening, so it does not help. Recovering severity
+  would mean reading eve.json directly over SSH, which is a bigger access grant
+  than this is worth. Alerts are therefore ranked by count, and the prompt is
+  told it cannot see severity.
+
+  Packet capture is deliberately not triggered from here. OPNsense does expose
+  Interfaces: Diagnostics: Packet Capture over the API
+  (`/api/diagnostics/packet_capture/...`), but `set`, `start`, `stop` and
+  `remove` are all POSTs that write a job file to /tmp/captures and spawn
+  tcpdump. Starting a capture is a write action on the firewall and is barred by
+  the invariant above, quite apart from it being the wrong design — see the
+  intrusion-detection section of digest-engine/README.md for how to keep a
+  rotating local capture on OPNsense itself and merely *point* at it from the
+  digest via `packet_capture_reference`.
+
+Configuration is a JSON file, not env, because the user asked for `IDSconf.json`
+specifically. Path comes from OPNSENSE_IDS_CONF_PATH (default /data/IDSconf.json)
+and the file is git-ignored; IDSconf.json.example is the committed template.
+"""
+
+import ipaddress
+import json
+import logging
+import math
+import os
+from collections import Counter
+from datetime import datetime, timedelta, timezone
+
+import requests
+
+LOG = logging.getLogger(__name__)
+
+DEFAULT_CONF_PATH = "/data/IDSconf.json"
+
+STATUS_PATH = "/api/ids/service/status"
+QUERY_ALERTS_PATH = "/api/ids/service/query_alerts"
+
+HTTP_TIMEOUT = 45
+ROWS_PER_PAGE = 500
+
+DEFAULT_MAX_ALERTS_SCANNED = 5000
+DEFAULT_TOP_SIGNATURES = 8
+DEFAULT_TOP_HOSTS = 5
+
+
+def _load_config(path):
+    try:
+        with open(path, encoding="utf-8") as handle:
+            config = json.load(handle)
+    except FileNotFoundError:
+        LOG.warning("opnsense_ids: %s not found, skipping (copy IDSconf.json.example there)", path)
+        return None
+    except Exception:
+        LOG.warning("opnsense_ids: %s could not be parsed, skipping", path, exc_info=True)
+        return None
+
+    missing = [key for key in ("base_url", "api_key", "api_secret") if not str(config.get(key, "")).strip()]
+    if missing:
+        LOG.warning("opnsense_ids: %s is missing %s, skipping", path, ", ".join(missing))
+        return None
+
+    return config
+
+
+def _parse_timestamp(raw):
+    if not raw:
+        return None
+    try:
+        parsed = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
+    except ValueError:
+        return None
+    # Suricata always writes an offset, but a naive value would raise on the
+    # comparison against the cutoff and lose the whole run over one bad line.
+    return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
+
+
+def _service_status(session, base_url, verify):
+    response = session.get(base_url + STATUS_PATH, timeout=HTTP_TIMEOUT, verify=verify)
+    response.raise_for_status()
+    return response.json().get("status", "unknown")
+
+
+def _collect_alerts(session, base_url, verify, cutoff, max_pages):
+    rows = []
+
+    for page in range(1, max_pages + 1):
+        response = session.post(
+            base_url + QUERY_ALERTS_PATH,
+            data={"rowCount": ROWS_PER_PAGE, "current": page},
+            timeout=HTTP_TIMEOUT,
+            verify=verify,
+        )
+        response.raise_for_status()
+        page_rows = response.json().get("rows") or []
+
+        if not page_rows:
+            # An empty first page means the log holds no alerts at all. An empty
+            # later page means eve.json ran out before the window did, i.e. it
+            # rotated mid-window and older alerts are in a file this does not read.
+            return rows, bool(rows)
+
+        for row in page_rows:
+            timestamp = _parse_timestamp(row.get("timestamp"))
+            if timestamp is None:
+                continue
+            if timestamp < cutoff:
+                return rows, False
+            rows.append(row)
+
+        if len(page_rows) < ROWS_PER_PAGE:
+            return rows, True
+
+    return rows, True
+
+
+def _host_role(ip):
+    # is_global rather than "not is_private" so CGNAT and the reserved ranges land
+    # on the household side of the split rather than being reported as the internet.
+    try:
+        return "remote" if ipaddress.ip_address(ip).is_global else "local"
+    except ValueError:
+        return None
+
+
+def _summarise(rows, truncated, status, config, window_start, window_end, lookback_hours):
+    actions = Counter()
+    interfaces = Counter()
+    local_hosts = Counter()
+    remote_hosts = Counter()
+    signatures = {}
+
+    for row in rows:
+        action = row.get("alert_action") or "unknown"
+        actions[action] += 1
+
+        interface = row.get("in_iface")
+        if interface:
+            interfaces[interface] += 1
+
+        for key in ("src_ip", "dest_ip"):
+            ip = row.get(key)
+            if not ip:
+                continue
+            role = _host_role(ip)
+            if role == "local":
+                local_hosts[ip] += 1
+            elif role == "remote":
+                remote_hosts[ip] += 1
+
+        sid = row.get("alert_sid")
+        entry = signatures.setdefault(
+            sid,
+            {
+                "sid": sid,
+                "signature": row.get("alert"),
+                "count": 0,
+                "actions": Counter(),
+                "sources": Counter(),
+                "destinations": Counter(),
+                "last_seen": row.get("timestamp"),
+            },
+        )
+        entry["count"] += 1
+        entry["actions"][action] += 1
+        if row.get("src_ip"):
+            entry["sources"][row["src_ip"]] += 1
+        if row.get("dest_ip"):
+            entry["destinations"][row["dest_ip"]] += 1
+
+    top_signatures = sorted(signatures.values(), key=lambda item: item["count"], reverse=True)
+    top_signatures = top_signatures[: int(config.get("top_signatures", DEFAULT_TOP_SIGNATURES))]
+    top_hosts = int(config.get("top_hosts", DEFAULT_TOP_HOSTS))
+
+    summary = {
+        "source": "opnsense_ids",
+        "category": "network_security",
+        "observed_at": window_end.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
+        "window_start": window_start.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
+        "window_hours": lookback_hours,
+        "ids_status": status,
+        "alert_count": len(rows),
+        "alerts_by_action": dict(actions),
+        "interfaces": dict(interfaces),
+        "top_signatures": [
+            {
+                "sid": entry["sid"],
+                "signature": entry["signature"],
+                "count": entry["count"],
+                "actions": dict(entry["actions"]),
+                "top_sources": [ip for ip, _ in entry["sources"].most_common(3)],
+                "top_destinations": [ip for ip, _ in entry["destinations"].most_common(3)],
+                "last_seen": entry["last_seen"],
+            }
+            for entry in top_signatures
+        ],
+        "top_local_hosts": [{"ip": ip, "alerts": count} for ip, count in local_hosts.most_common(top_hosts)],
+        "top_remote_hosts": [{"ip": ip, "alerts": count} for ip, count in remote_hosts.most_common(top_hosts)],
+        "window_truncated": truncated,
+        "caveat": (
+            "Suricata alerts are signature matches on traffic crossing the monitored "
+            "interfaces, not confirmed compromise — false positives are normal and a "
+            "single alert is not an incident. Severity is not exposed by the OPNsense "
+            "API, so these are ranked by frequency only. Encrypted traffic is largely "
+            "opaque to signature matching, and a quiet window is not evidence that "
+            "nothing happened."
+        ),
+    }
+
+    if truncated:
+        summary["truncation_note"] = (
+            "More alerts existed in this window than were read (max_alerts_scanned "
+            "reached, or the Suricata log rotated mid-window). Counts are a lower bound."
+        )
+
+    reference = str(config.get("packet_capture_reference", "")).strip()
+    if reference:
+        summary["packet_capture_reference"] = reference
+
+    return summary
+
+
+def fetch(lookback_hours):
+    config = _load_config(os.environ.get("OPNSENSE_IDS_CONF_PATH", DEFAULT_CONF_PATH))
+    if config is None:
+        return []
+
+    base_url = str(config["base_url"]).strip().rstrip("/")
+    verify = config.get("verify_tls", True)
+    if verify is False:
+        LOG.warning("opnsense_ids: verify_tls is false, the API session is unauthenticated against MITM")
+
+    window_end = datetime.now(timezone.utc)
+    window_start = window_end - timedelta(hours=float(lookback_hours))
+    max_pages = max(1, math.ceil(int(config.get("max_alerts_scanned", DEFAULT_MAX_ALERTS_SCANNED)) / ROWS_PER_PAGE))
+
+    session = requests.Session()
+    session.auth = (str(config["api_key"]).strip(), str(config["api_secret"]).strip())
+
+    try:
+        status = _service_status(session, base_url, verify)
+    except Exception:
+        # Without the status call a run cannot tell "no alerts" from "Suricata was
+        # off the whole window", which is the difference between reassuring and
+        # meaningless. Carry on so the alert query still gets its chance.
+        LOG.warning("opnsense_ids: service status query failed", exc_info=True)
+        status = "unknown"
+
+    try:
+        rows, truncated = _collect_alerts(session, base_url, verify, window_start, max_pages)
+    except Exception:
+        LOG.warning("opnsense_ids: alert query failed, skipping", exc_info=True)
+        return []
+
+    interface_filter = [str(item).strip() for item in config.get("interfaces", []) if str(item).strip()]
+    if interface_filter:
+        rows = [row for row in rows if row.get("in_iface") in interface_filter]
+
+    summary = _summarise(rows, truncated, status, config, window_start, window_end, lookback_hours)
+    LOG.info("opnsense_ids: %d alert(s) in the last %sh, IDS %s", summary["alert_count"], lookback_hours, status)
+    return [summary]
diff --git a/digest-engine/ingest/signal_ingest.py b/digest-engine/ingest/signal_ingest.py
new file mode 100644
index 0000000..45f2dd9
--- /dev/null
+++ b/digest-engine/ingest/signal_ingest.py
@@ -0,0 +1,97 @@
+"""Signal ingestion via a signal-cli JSON-RPC daemon.
+
+signal-cli is NOT containerised by digest-engine — assume a separate compose
+service (e.g. `signal-cli`, image `bbernhard/signal-cli-rest-api` or a hand-rolled
+`signal-cli daemon --http`) already linked as a secondary device to the real
+account, reachable on the compose network at SIGNAL_CLI_URL (e.g.
+http://signal-cli:8080). Method names/params follow
+https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc
+
+`receive` drains the account's server-side envelope queue. That drain is
+protocol-mandated to read anything at all (Signal has no "peek" primitive) and is
+the single exception permitted by the read-only rule — it sends no message, sets
+no read receipt (`sendReadReceipts` is left at its default off) and mutates no
+conversation.
+"""
+
+import logging
+import os
+import uuid
+from datetime import datetime, timedelta, timezone
+
+import requests
+
+LOG = logging.getLogger(__name__)
+
+RPC_PATH = "/api/v1/rpc"
+
+
+def _envelope_to_record(envelope):
+    data_message = envelope.get("dataMessage") or {}
+    body = data_message.get("message")
+    if not body:
+        return None
+    timestamp_ms = envelope.get("timestamp") or data_message.get("timestamp")
+    sent_at = (
+        datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc) if timestamp_ms else None
+    )
+    group_info = data_message.get("groupInfo") or {}
+    return {
+        "source": "signal",
+        "from": envelope.get("sourceName") or envelope.get("sourceNumber") or envelope.get("source"),
+        "group": group_info.get("groupName") or group_info.get("groupId"),
+        "timestamp": sent_at.isoformat() if sent_at else None,
+        "body": body,
+    }
+
+
+def fetch(lookback_hours):
+    base_url = os.environ.get("SIGNAL_CLI_URL", "").strip().rstrip("/")
+    account = os.environ.get("SIGNAL_ACCOUNT", "").strip()
+    timeout = float(os.environ.get("SIGNAL_RECEIVE_TIMEOUT", "10"))
+
+    if not base_url:
+        LOG.warning("signal: SIGNAL_CLI_URL not set, skipping")
+        return []
+
+    params = {"timeout": timeout, "sendReadReceipts": False}
+    if account:
+        params["account"] = account
+
+    payload = {
+        "jsonrpc": "2.0",
+        "id": str(uuid.uuid4()),
+        "method": "receive",
+        "params": params,
+    }
+
+    try:
+        response = requests.post(base_url + RPC_PATH, json=payload, timeout=timeout + 15)
+        response.raise_for_status()
+        result = response.json()
+    except Exception:
+        LOG.warning("signal: JSON-RPC call to %s failed, returning nothing", base_url, exc_info=True)
+        return []
+
+    if "error" in result:
+        LOG.warning("signal: daemon returned an error: %s", result["error"])
+        return []
+
+    since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours)
+    messages = []
+    for item in result.get("result") or []:
+        try:
+            envelope = item.get("envelope") if isinstance(item, dict) else None
+            if not envelope:
+                continue
+            record = _envelope_to_record(envelope)
+            if not record:
+                continue
+            if record["timestamp"] and datetime.fromisoformat(record["timestamp"]) < since:
+                continue
+            messages.append(record)
+        except Exception:
+            LOG.warning("signal: could not parse an envelope, skipping", exc_info=True)
+
+    LOG.info("signal: %d message(s) in the last %sh", len(messages), lookback_hours)
+    return messages
diff --git a/digest-engine/ingest/telegram_ingest.py b/digest-engine/ingest/telegram_ingest.py
new file mode 100644
index 0000000..5ef9b6e
--- /dev/null
+++ b/digest-engine/ingest/telegram_ingest.py
@@ -0,0 +1,101 @@
+"""Telegram ingestion via Telethon (MTProto).
+
+This logs in as the real user account, not a bot — the Bot API cannot read
+personal DMs, so there is no bot-shaped way to do this (see docs/project-plan.md
+Phase 12 step 4). It runs strictly non-interactively: the session file at
+TELEGRAM_SESSION_PATH must already exist, created once by hand with
+`python ingest/telegram_login.py`. If it doesn't, or it has been invalidated,
+this module warns and returns nothing rather than blocking a scheduled run on a
+phone-code prompt nobody is there to answer.
+
+Reading messages does not acknowledge them — Telethon only marks a chat read on
+an explicit `send_read_acknowledge()`, which is never called here.
+"""
+
+import asyncio
+import logging
+import os
+from datetime import datetime, timedelta, timezone
+
+LOG = logging.getLogger(__name__)
+
+MAX_BODY_CHARS = 2000
+DEFAULT_SESSION_PATH = "/data/telegram.session"
+
+
+def _session_path():
+    return os.environ.get("TELEGRAM_SESSION_PATH", DEFAULT_SESSION_PATH)
+
+
+async def _fetch_async(lookback_hours, api_id, api_hash, max_dialogs, max_per_dialog):
+    from telethon import TelegramClient
+
+    since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours)
+    messages = []
+
+    client = TelegramClient(_session_path(), api_id, api_hash)
+    await client.connect()
+    try:
+        if not await client.is_user_authorized():
+            LOG.warning(
+                "telegram: session at %s is not authorized — run `python ingest/telegram_login.py` once",
+                _session_path(),
+            )
+            return []
+
+        async for dialog in client.iter_dialogs(limit=max_dialogs):
+            try:
+                async for message in client.iter_messages(dialog.entity, limit=max_per_dialog):
+                    if message.date is None or message.date < since:
+                        break
+                    if not message.message:
+                        continue
+                    sender = await message.get_sender()
+                    sender_name = getattr(sender, "username", None) or getattr(
+                        sender, "first_name", None
+                    )
+                    messages.append(
+                        {
+                            "source": "telegram",
+                            "chat": dialog.name,
+                            "from": sender_name or "unknown",
+                            "outgoing": bool(message.out),
+                            "timestamp": message.date.isoformat(),
+                            "body": message.message[:MAX_BODY_CHARS],
+                        }
+                    )
+            except Exception:
+                LOG.warning("telegram: could not read dialog %r, skipping", dialog.name, exc_info=True)
+    finally:
+        await client.disconnect()
+
+    return messages
+
+
+def fetch(lookback_hours):
+    api_id = os.environ.get("TELEGRAM_API_ID", "").strip()
+    api_hash = os.environ.get("TELEGRAM_API_HASH", "").strip()
+    max_dialogs = int(os.environ.get("TELEGRAM_MAX_DIALOGS", "25"))
+    max_per_dialog = int(os.environ.get("TELEGRAM_MAX_MESSAGES_PER_DIALOG", "50"))
+
+    if not (api_id and api_hash):
+        LOG.warning("telegram: TELEGRAM_API_ID/TELEGRAM_API_HASH not set, skipping")
+        return []
+
+    if not os.path.exists(_session_path()):
+        LOG.warning(
+            "telegram: no session file at %s — run `python ingest/telegram_login.py` once, skipping",
+            _session_path(),
+        )
+        return []
+
+    try:
+        messages = asyncio.run(
+            _fetch_async(lookback_hours, int(api_id), api_hash, max_dialogs, max_per_dialog)
+        )
+    except Exception:
+        LOG.warning("telegram: ingestion failed, returning nothing", exc_info=True)
+        return []
+
+    LOG.info("telegram: %d message(s) in the last %sh", len(messages), lookback_hours)
+    return messages
diff --git a/digest-engine/ingest/telegram_login.py b/digest-engine/ingest/telegram_login.py
new file mode 100644
index 0000000..4f55055
--- /dev/null
+++ b/digest-engine/ingest/telegram_login.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+"""One-time interactive Telegram login — run this by hand, once, before the first
+scheduled digest run.
+
+    docker compose run --rm --entrypoint python digest-engine ingest/telegram_login.py
+
+It prompts for the phone number, the code Telegram sends, and the 2FA password if
+the account has one, then writes the session to TELEGRAM_SESSION_PATH (a mounted
+volume, so it survives `--rm`). telegram_ingest.py reuses that session
+non-interactively from then on.
+
+Get TELEGRAM_API_ID / TELEGRAM_API_HASH from https://my.telegram.org -> API
+development tools.
+"""
+
+import os
+import sys
+
+from telethon import TelegramClient
+
+DEFAULT_SESSION_PATH = "/data/telegram.session"
+
+
+def main():
+    api_id = os.environ.get("TELEGRAM_API_ID", "").strip()
+    api_hash = os.environ.get("TELEGRAM_API_HASH", "").strip()
+    session_path = os.environ.get("TELEGRAM_SESSION_PATH", DEFAULT_SESSION_PATH)
+
+    if not (api_id and api_hash):
+        print("TELEGRAM_API_ID and TELEGRAM_API_HASH must be set (see digest-engine.env.example).", file=sys.stderr)
+        return 1
+
+    print(f"Creating Telegram session at {session_path}")
+    with TelegramClient(session_path, int(api_id), api_hash) as client:
+        me = client.get_me()
+        print(f"Logged in as {me.username or me.first_name} (id {me.id}).")
+
+    print("Done. telegram_ingest.py will reuse this session non-interactively.")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/digest-engine/ingest/whatsapp_ingest.py b/digest-engine/ingest/whatsapp_ingest.py
new file mode 100644
index 0000000..6e20ba1
--- /dev/null
+++ b/digest-engine/ingest/whatsapp_ingest.py
@@ -0,0 +1,87 @@
+"""WhatsApp ingestion — the Python-side consumer of the whatsapp-bridge sidecar.
+
+The bridge (digest-engine/whatsapp-bridge/) runs a real, headful Chromium logged
+into web.whatsapp.com and appends one JSON object per received message to a
+shared file on a Docker volume. This module drains that file each run.
+
+A file on a shared volume beats an HTTP call to the bridge here: a digest run
+fires on a systemd timer with no regard for whether the bridge container happens
+to be restarting, re-authenticating, or mid-Chromium-crash. Messages the bridge
+already wrote are still on disk and still get read; there is no liveness or
+retry/backoff coordination problem to solve.
+
+Draining is a rename-then-read, not a truncate-in-place: the bridge appends with
+an open-write-close per message, so renaming the file out from under it is atomic
+from the reader's side and the bridge simply recreates the original path on its
+next append. A read-then-truncate would silently drop anything written in between.
+
+This is opt-in and off by default — see the ban-risk warning in README.md.
+"""
+
+import json
+import logging
+import os
+from datetime import datetime, timedelta, timezone
+
+LOG = logging.getLogger(__name__)
+
+DEFAULT_MESSAGES_PATH = "/data/whatsapp-bridge/messages.jsonl"
+MAX_BODY_CHARS = 2000
+
+
+def fetch(lookback_hours):
+    messages_path = os.environ.get("WHATSAPP_MESSAGES_PATH", DEFAULT_MESSAGES_PATH)
+    drained_path = messages_path + ".draining"
+
+    if not os.path.exists(messages_path):
+        LOG.warning(
+            "whatsapp: no message file at %s (is whatsapp-bridge running and logged in?), skipping",
+            messages_path,
+        )
+        return []
+
+    try:
+        os.replace(messages_path, drained_path)
+    except OSError:
+        LOG.warning("whatsapp: could not claim %s for reading, skipping", messages_path, exc_info=True)
+        return []
+
+    since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours)
+    messages = []
+
+    try:
+        with open(drained_path, "r", encoding="utf-8") as handle:
+            for line in handle:
+                line = line.strip()
+                if not line:
+                    continue
+                try:
+                    record = json.loads(line)
+                    timestamp = record.get("timestamp")
+                    sent_at = (
+                        datetime.fromtimestamp(timestamp, tz=timezone.utc) if timestamp else None
+                    )
+                    if sent_at and sent_at < since:
+                        continue
+                    messages.append(
+                        {
+                            "source": "whatsapp",
+                            "from": record.get("from_name") or record.get("from"),
+                            "chat": record.get("chat"),
+                            "timestamp": sent_at.isoformat() if sent_at else None,
+                            "body": (record.get("body") or "")[:MAX_BODY_CHARS],
+                        }
+                    )
+                except Exception:
+                    LOG.warning("whatsapp: could not parse a bridge line, skipping", exc_info=True)
+    except Exception:
+        LOG.warning("whatsapp: could not read %s, returning nothing", drained_path, exc_info=True)
+        return []
+    finally:
+        try:
+            os.remove(drained_path)
+        except OSError:
+            LOG.warning("whatsapp: could not remove %s", drained_path, exc_info=True)
+
+    LOG.info("whatsapp: %d message(s) in the last %sh", len(messages), lookback_hours)
+    return messages
diff --git a/digest-engine/render/digest-canvas-sdk/globe.js b/digest-engine/render/digest-canvas-sdk/globe.js
new file mode 100644
index 0000000..8c50710
--- /dev/null
+++ b/digest-engine/render/digest-canvas-sdk/globe.js
@@ -0,0 +1,245 @@
+/*
+ * DigestGlobe — a rotating "holo globe" for the political digest section.
+ *
+ * SVG orthographic projection, not CSS 3D transforms. CSS 3D can fake a sphere
+ * (stacked rotated rings, a textured div under perspective), but there is then no
+ * honest way to answer "where on screen is 34.05N, 118.24W" — you would be
+ * eyeballing marker placement against a fake, and it drifts as soon as anything
+ * about the container changes. Orthographic projection is four lines of
+ * trigonometry, gives exact marker positions and exact back-of-globe visibility,
+ * needs no 3D library, and the same maths projects both the markers and the
+ * coastline outline so they can never disagree.
+ *
+ * No external dependencies, no CDN, no network access of any kind — the world
+ * outline below is inlined as coarse lat/lon polylines. It is deliberately
+ * low-poly: recognisable as Earth, not a cartographic reference.
+ */
+
+(function (global) {
+  'use strict';
+
+  var RADIUS = 95;              // in viewBox units; viewBox is -100..100 on both axes
+  var DEG = Math.PI / 180;
+
+  // [lon, lat] rings, coarse. Antarctica is omitted deliberately: at this vertex
+  // count it reads as noise around the south limb rather than as a continent.
+  var WORLD = [
+    // Africa
+    [[-17, 14], [-16, 20], [-5, 30], [10, 37], [20, 32], [32, 31], [43, 12], [51, 12],
+     [41, -2], [40, -16], [35, -24], [20, -35], [18, -33], [12, -17], [9, 4], [-8, 4],
+     [-13, 9], [-17, 14]],
+    // Eurasia
+    [[-10, 36], [-2, 43], [3, 43], [9, 44], [16, 41], [23, 38], [28, 41], [35, 36],
+     [36, 31], [43, 30], [48, 30], [57, 25], [67, 24], [72, 19], [77, 8], [80, 13],
+     [87, 21], [92, 22], [97, 17], [100, 13], [104, 10], [109, 15], [117, 23],
+     [122, 31], [122, 40], [127, 39], [129, 43], [135, 48], [140, 53], [143, 59],
+     [160, 61], [170, 66], [180, 68], [160, 70], [140, 73], [110, 74], [80, 73],
+     [60, 71], [40, 68], [30, 70], [20, 70], [10, 64], [5, 58], [0, 51], [-5, 48],
+     [-10, 43], [-9, 39], [-6, 37], [-10, 36]],
+    // North America
+    [[-168, 66], [-160, 58], [-150, 60], [-135, 58], [-125, 49], [-122, 37], [-117, 32],
+     [-105, 23], [-97, 16], [-94, 18], [-90, 21], [-84, 22], [-81, 25], [-81, 31],
+     [-76, 35], [-70, 42], [-66, 45], [-60, 47], [-56, 52], [-64, 60], [-78, 62],
+     [-85, 70], [-95, 70], [-110, 69], [-125, 70], [-140, 70], [-155, 71], [-168, 66]],
+    // South America
+    [[-81, -5], [-79, 0], [-76, 8], [-71, 12], [-62, 10], [-52, 5], [-50, 0], [-44, -2],
+     [-38, -5], [-35, -8], [-39, -16], [-48, -25], [-54, -34], [-58, -38], [-62, -40],
+     [-65, -45], [-68, -52], [-75, -52], [-73, -45], [-73, -37], [-71, -30], [-70, -20],
+     [-76, -14], [-81, -5]],
+    // Australia
+    [[113, -22], [114, -27], [118, -34], [126, -32], [133, -32], [138, -35], [145, -38],
+     [150, -37], [153, -28], [146, -19], [142, -11], [136, -12], [130, -11], [125, -14],
+     [118, -20], [113, -22]],
+    // Greenland
+    [[-45, 60], [-52, 65], [-55, 70], [-60, 76], [-50, 82], [-30, 83], [-22, 74],
+     [-30, 68], [-42, 61], [-45, 60]]
+  ];
+
+  var ICONS = {
+    'hammer-sickle': '☭',
+    star: '★',
+    default: '●'
+  };
+
+  var SVG_NS = 'http://www.w3.org/2000/svg';
+
+  function svgEl(name, attrs) {
+    var el = document.createElementNS(SVG_NS, name);
+    Object.keys(attrs || {}).forEach(function (key) {
+      el.setAttribute(key, attrs[key]);
+    });
+    return el;
+  }
+
+  // Orthographic projection about a rotating central meridian.
+  // Returns {x, y} in viewBox units and z, the cosine of angular distance from
+  // the projection centre — z <= 0 means the point is on the far side of the globe.
+  function project(lat, lon, lambda0) {
+    var phi = lat * DEG;
+    var lam = (lon - lambda0) * DEG;
+    return {
+      x: RADIUS * Math.cos(phi) * Math.sin(lam),
+      y: -RADIUS * Math.sin(phi),
+      z: Math.cos(phi) * Math.cos(lam)
+    };
+  }
+
+  function DigestGlobe(container, options) {
+    options = options || {};
+
+    this.container = container;
+    this.lambda0 = typeof options.longitude === 'number' ? options.longitude : 0;
+    this.speed = typeof options.speed === 'number' ? options.speed : 4; // degrees/second
+    this.markers = [];
+
+    container.classList.add('digest-globe');
+
+    this.svg = svgEl('svg', {
+      viewBox: '-100 -100 200 200',
+      class: 'digest-globe-svg',
+      'aria-hidden': 'true'
+    });
+    this.svg.appendChild(svgEl('circle', { cx: 0, cy: 0, r: RADIUS, class: 'digest-globe-ocean' }));
+
+    this.graticule = svgEl('g', { class: 'digest-globe-graticule' });
+    this.land = svgEl('g', { class: 'digest-globe-land' });
+    this.svg.appendChild(this.graticule);
+    this.svg.appendChild(this.land);
+    this.svg.appendChild(svgEl('circle', { cx: 0, cy: 0, r: RADIUS, class: 'digest-globe-limb' }));
+    container.appendChild(this.svg);
+
+    this.markerLayer = document.createElement('div');
+    this.markerLayer.className = 'digest-globe-markers';
+    container.appendChild(this.markerLayer);
+
+    this.landPaths = WORLD.map(function () {
+      var path = svgEl('path', {});
+      this.land.appendChild(path);
+      return path;
+    }, this);
+
+    this.graticulePaths = [];
+    for (var lon = -180; lon < 180; lon += 30) {
+      this.graticulePaths.push({ path: this.graticule.appendChild(svgEl('path', {})), meridian: lon });
+    }
+    for (var lat = -60; lat <= 60; lat += 30) {
+      this.graticulePaths.push({ path: this.graticule.appendChild(svgEl('path', {})), parallel: lat });
+    }
+
+    this.draw();
+    this.start();
+  }
+
+  // Builds an SVG path from a lat/lon ring, breaking the path wherever the ring
+  // crosses the limb so the far side of the globe isn't drawn through the near side.
+  DigestGlobe.prototype._ringPath = function (ring) {
+    var parts = [];
+    var pen = false;
+    for (var i = 0; i < ring.length; i++) {
+      var point = project(ring[i][1], ring[i][0], this.lambda0);
+      if (point.z <= 0) {
+        pen = false;
+        continue;
+      }
+      parts.push((pen ? 'L' : 'M') + point.x.toFixed(2) + ' ' + point.y.toFixed(2));
+      pen = true;
+    }
+    return parts.join(' ');
+  };
+
+  DigestGlobe.prototype.draw = function () {
+    this.landPaths.forEach(function (path, index) {
+      path.setAttribute('d', this._ringPath(WORLD[index]));
+    }, this);
+
+    this.graticulePaths.forEach(function (entry) {
+      var ring = [];
+      var step;
+      if (entry.meridian !== undefined) {
+        for (step = -90; step <= 90; step += 5) { ring.push([entry.meridian, step]); }
+      } else {
+        for (step = -180; step <= 180; step += 5) { ring.push([step, entry.parallel]); }
+      }
+      entry.path.setAttribute('d', this._ringPath(ring));
+    }, this);
+
+    this.markers.forEach(function (marker) {
+      var point = project(marker.lat, marker.lon, this.lambda0);
+      if (point.z <= 0) {
+        marker.el.style.opacity = '0';
+        marker.el.style.pointerEvents = 'none';
+        return;
+      }
+      marker.el.style.opacity = String(Math.min(1, 0.25 + point.z * 1.4));
+      marker.el.style.pointerEvents = 'auto';
+      marker.el.style.left = (50 + point.x / 2) + '%';
+      marker.el.style.top = (50 + point.y / 2) + '%';
+    }, this);
+  };
+
+  DigestGlobe.prototype.addMarker = function (lat, lon, options) {
+    options = options || {};
+
+    var el = document.createElement('span');
+    el.className = 'digest-globe-marker';
+
+    var icon = document.createElement('span');
+    var iconName = ICONS[options.icon] ? options.icon : 'default';
+    icon.className = 'digest-marker-icon digest-icon-' + iconName;
+    icon.textContent = ICONS[iconName];
+    if (options.color) {
+      icon.style.color = options.color;
+      icon.style.setProperty('--digest-glow-color', options.color);
+    }
+    // The glow class goes on the icon, not the wrapper: text-shadow is inherited
+    // as an already-resolved value, so a var() set on a child cannot re-colour a
+    // shadow declared on its parent.
+    if (options.glow) {
+      icon.classList.add('digest-glow');
+    }
+    el.appendChild(icon);
+
+    if (options.label) {
+      var label = document.createElement('span');
+      label.className = 'digest-marker-label';
+      label.textContent = options.label;
+      el.appendChild(label);
+      el.title = options.label;
+    }
+
+    this.markerLayer.appendChild(el);
+
+    var marker = { lat: Number(lat) || 0, lon: Number(lon) || 0, el: el };
+    this.markers.push(marker);
+    this.draw();
+    return marker;
+  };
+
+  DigestGlobe.prototype.start = function () {
+    if (this.frame) { return; }
+    if (global.matchMedia && global.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+      return;
+    }
+
+    var self = this;
+    var last = null;
+    var tick = function (now) {
+      if (last !== null) {
+        self.lambda0 = (self.lambda0 + self.speed * (now - last) / 1000 + 180) % 360 - 180;
+        self.draw();
+      }
+      last = now;
+      self.frame = global.requestAnimationFrame(tick);
+    };
+    this.frame = global.requestAnimationFrame(tick);
+  };
+
+  DigestGlobe.prototype.stop = function () {
+    if (this.frame) {
+      global.cancelAnimationFrame(this.frame);
+      this.frame = null;
+    }
+  };
+
+  global.DigestGlobe = DigestGlobe;
+})(window);
diff --git a/digest-engine/render/digest-canvas-sdk/glow.css b/digest-engine/render/digest-canvas-sdk/glow.css
new file mode 100644
index 0000000..d1e94d9
--- /dev/null
+++ b/digest-engine/render/digest-canvas-sdk/glow.css
@@ -0,0 +1,265 @@
+/*
+ * digest-canvas SDK styling — the whole SDK's CSS, in one file, no build step.
+ * Loaded with a plain ; nothing here reaches the network.
+ */
+
+:root {
+  --digest-bg: #070b12;
+  --digest-panel: rgba(16, 24, 38, 0.82);
+  --digest-edge: rgba(120, 180, 255, 0.28);
+  --digest-text: #d7e3f4;
+  --digest-muted: #8ba0bd;
+  --digest-accent: #8ab4ff;
+  --digest-red: #e00000;
+  --digest-glow-color: var(--digest-accent);
+}
+
+/* ---------------------------------------------------------------------------
+ * Glow / holo utilities — shared by globe markers and window chrome.
+ * --------------------------------------------------------------------------- */
+
+.digest-glow {
+  text-shadow:
+    0 0 4px var(--digest-glow-color),
+    0 0 12px var(--digest-glow-color);
+  animation: digest-pulse 3.2s ease-in-out infinite;
+}
+
+.digest-glow-box {
+  box-shadow:
+    0 0 0 1px var(--digest-edge),
+    0 0 24px -6px var(--digest-glow-color),
+    0 18px 40px -24px rgba(0, 0, 0, 0.9);
+}
+
+@keyframes digest-pulse {
+  0%, 100% { filter: brightness(1); }
+  50% { filter: brightness(1.45); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .digest-glow { animation: none; }
+}
+
+/* ---------------------------------------------------------------------------
+ * Canvas
+ * --------------------------------------------------------------------------- */
+
+.digest-canvas {
+  position: relative;
+  box-sizing: border-box;
+  color: var(--digest-text);
+  background: radial-gradient(circle at 50% 0%, #0d1626 0%, var(--digest-bg) 70%);
+  font-family: "Inter", "Noto Sans", "DejaVu Sans", system-ui, sans-serif;
+}
+
+.digest-canvas-empty,
+.digest-canvas-raw {
+  margin: 1rem;
+  padding: 1rem;
+  border: 1px solid var(--digest-edge);
+  border-radius: 8px;
+  color: var(--digest-muted);
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-family: "DejaVu Sans Mono", ui-monospace, monospace;
+  font-size: 0.8rem;
+  line-height: 1.5;
+}
+
+/* ---------------------------------------------------------------------------
+ * Window chrome
+ * --------------------------------------------------------------------------- */
+
+.digest-window {
+  position: relative;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  border: 1px solid var(--digest-edge);
+  border-radius: 12px;
+  background: var(--digest-panel);
+  backdrop-filter: blur(6px);
+  box-shadow:
+    0 0 24px -10px var(--digest-accent),
+    0 20px 46px -28px rgba(0, 0, 0, 0.95);
+  overflow: hidden;
+}
+
+.digest-window-positioned {
+  position: absolute;
+}
+
+.digest-window-titlebar {
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+  padding: 0.55rem 0.85rem;
+  border-bottom: 1px solid var(--digest-edge);
+  background: linear-gradient(180deg, rgba(120, 180, 255, 0.12), rgba(120, 180, 255, 0.02));
+}
+
+.digest-window-dots {
+  flex: none;
+  width: 34px;
+  height: 8px;
+  background-image: radial-gradient(circle, var(--digest-accent) 3px, transparent 3px);
+  background-size: 12px 8px;
+  background-repeat: repeat-x;
+  opacity: 0.6;
+}
+
+.digest-window-title {
+  margin: 0;
+  font-size: 0.82rem;
+  font-weight: 600;
+  letter-spacing: 0.09em;
+  text-transform: uppercase;
+  color: var(--digest-accent);
+}
+
+.digest-window-body {
+  flex: 1 1 auto;
+  padding: 0.8rem 0.95rem 1rem;
+  font-size: 0.92rem;
+  line-height: 1.55;
+  overflow: auto;
+}
+
+.digest-window-body p { margin: 0 0 0.7rem; }
+.digest-window-body p:last-child { margin-bottom: 0; }
+.digest-window-body code {
+  padding: 0.05em 0.35em;
+  border-radius: 4px;
+  background: rgba(120, 180, 255, 0.12);
+  font-family: "DejaVu Sans Mono", ui-monospace, monospace;
+  font-size: 0.88em;
+}
+
+.digest-window-list {
+  margin: 0;
+  padding-left: 1.1rem;
+}
+.digest-window-list li { margin-bottom: 0.4rem; }
+
+.digest-window-error {
+  margin: 0;
+  padding: 0.8rem 0.95rem;
+  color: var(--digest-muted);
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-family: "DejaVu Sans Mono", ui-monospace, monospace;
+  font-size: 0.78rem;
+}
+
+.digest-window-globe .digest-window-body {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+/* ---------------------------------------------------------------------------
+ * Globe
+ * --------------------------------------------------------------------------- */
+
+.digest-globe {
+  position: relative;
+  width: 100%;
+  max-width: 520px;
+  aspect-ratio: 1 / 1;
+  margin: 0 auto;
+}
+
+.digest-globe-caption {
+  margin: 0.7rem 0 0;
+  font-size: 0.85rem;
+  color: var(--digest-muted);
+}
+
+.digest-globe-svg {
+  display: block;
+  width: 100%;
+  height: 100%;
+  overflow: visible;
+}
+
+.digest-globe-ocean {
+  fill: #0b1728;
+  stroke: none;
+}
+
+.digest-globe-limb {
+  fill: none;
+  stroke: var(--digest-accent);
+  stroke-width: 0.8;
+  opacity: 0.65;
+  filter: drop-shadow(0 0 6px var(--digest-accent));
+}
+
+.digest-globe-graticule path {
+  fill: none;
+  stroke: var(--digest-accent);
+  stroke-width: 0.35;
+  opacity: 0.18;
+}
+
+.digest-globe-land path {
+  fill: none;
+  stroke: var(--digest-accent);
+  stroke-width: 0.9;
+  opacity: 0.75;
+}
+
+.digest-globe-markers {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+}
+
+.digest-globe-marker {
+  position: absolute;
+  display: flex;
+  align-items: center;
+  gap: 0.3rem;
+  transform: translate(-50%, -50%);
+  transition: opacity 120ms linear;
+  white-space: nowrap;
+}
+
+.digest-marker-icon {
+  font-size: 1.05rem;
+  line-height: 1;
+  color: var(--digest-accent);
+}
+
+.digest-marker-label {
+  padding: 0.1rem 0.35rem;
+  border-radius: 4px;
+  background: rgba(7, 11, 18, 0.72);
+  font-size: 0.62rem;
+  letter-spacing: 0.04em;
+  color: var(--digest-text);
+}
+
+/*
+ * ☭ (U+262D HAMMER AND SICKLE). Nerd Fonts patches do NOT carry this codepoint,
+ * so the usual kiosk font stack renders it as tofu. This stack lists the fonts
+ * that actually ship the glyph (Noto Symbols 2 and DejaVu Sans are both present
+ * on a stock Debian thin-client image) ahead of any UI font, and the emoji
+ * fallbacks cover a browser that only has a colour font for it.
+ */
+.digest-icon-hammer-sickle {
+  font-family: "Noto Sans Symbols 2", "Noto Sans Symbols2", "Symbola", "DejaVu Sans",
+    "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
+  font-size: 1.25rem;
+  color: var(--digest-red);
+  --digest-glow-color: var(--digest-red);
+  text-shadow:
+    0 0 5px var(--digest-red),
+    0 0 14px var(--digest-red);
+}
+
+.digest-icon-star {
+  font-family: "DejaVu Sans", "Noto Sans Symbols 2", "Segoe UI Symbol", sans-serif;
+}
diff --git a/digest-engine/render/digest-canvas-sdk/render.js b/digest-engine/render/digest-canvas-sdk/render.js
new file mode 100644
index 0000000..b14ed30
--- /dev/null
+++ b/digest-engine/render/digest-canvas-sdk/render.js
@@ -0,0 +1,182 @@
+/*
+ * DigestRender — turns digest JSON into a canvas of DigestWindows.
+ *
+ * Everything in here is written around one requirement from the plan's testing
+ * checklist: "If a run's LLM output produces malformed canvas-SDK calls, does the
+ * digest fall back to plain text instead of a broken/blank page?" A local model
+ * emitting near-miss JSON four times a day is a routine event, not an exception,
+ * so every layer degrades instead of throwing:
+ *
+ *   - unparseable input          -> a 
 dump of the raw text
+ *   - a section that isn't a doc -> a 
 dump of that section, others still render
+ *   - a window that throws       -> a 
 dump of that window, siblings still render
+ *   - a globe with no markers    -> the globe still draws, just empty
+ *
+ * A blank page is the one outcome that must never happen.
+ */
+
+(function (global) {
+  'use strict';
+
+  function rawDump(container, value, note) {
+    var pre = document.createElement('pre');
+    pre.className = 'digest-canvas-raw';
+    var text = typeof value === 'string' ? value : safeStringify(value);
+    pre.textContent = (note ? note + '\n\n' : '') + text;
+    container.appendChild(pre);
+    return pre;
+  }
+
+  function safeStringify(value) {
+    try {
+      return JSON.stringify(value, null, 2);
+    } catch (err) {
+      return String(value);
+    }
+  }
+
+  function parse(input) {
+    if (typeof input !== 'string') { return input; }
+    try {
+      return JSON.parse(input);
+    } catch (err) {
+      return { __unparseable: input };
+    }
+  }
+
+  // Accepts what run.py writes ({sections: {compact: [...], full: [...]}}), a bare
+  // array of section documents, or a single section document.
+  function toSectionList(parsed, detailLevel) {
+    if (Array.isArray(parsed)) { return parsed; }
+    if (!parsed || typeof parsed !== 'object') { return []; }
+    if (parsed.sections && typeof parsed.sections === 'object') {
+      var sections = parsed.sections;
+      if (Array.isArray(sections)) { return sections; }
+      var picked = sections[detailLevel] || sections.full || sections.compact;
+      return Array.isArray(picked) ? picked : [];
+    }
+    if (parsed.windows) { return [parsed]; }
+    return [];
+  }
+
+  function renderGlobeWindow(container, win) {
+    var mount = document.createElement('div');
+    var el = global.DigestWindow.open({
+      title: win.title || 'Globe',
+      content: mount,
+      container: container,
+      variant: 'globe'
+    });
+
+    var globe = new global.DigestGlobe(mount);
+    (win.globe_markers || []).forEach(function (marker) {
+      try {
+        globe.addMarker(marker.lat, marker.lon, {
+          icon: marker.icon,
+          color: marker.color,
+          glow: marker.glow,
+          label: marker.label
+        });
+      } catch (err) {
+        // One bad marker must not cost the whole globe.
+        if (global.console) { global.console.warn('digest: skipped a globe marker', err); }
+      }
+    });
+
+    if (typeof win.content === 'string' && win.content.trim()) {
+      var caption = document.createElement('p');
+      caption.className = 'digest-globe-caption';
+      caption.textContent = win.content;
+      mount.parentNode.appendChild(caption);
+    }
+
+    return el;
+  }
+
+  function renderWindow(container, win) {
+    if (!win || typeof win !== 'object') {
+      return rawDump(container, win, '// malformed window');
+    }
+    if (win.kind === 'globe') {
+      return renderGlobeWindow(container, win);
+    }
+    return global.DigestWindow.open({
+      title: win.title || '',
+      content: win.content,
+      container: container,
+      x: win.x,
+      y: win.y,
+      w: win.w,
+      h: win.h
+    });
+  }
+
+  function renderSection(container, doc) {
+    if (!doc || typeof doc !== 'object' || !Array.isArray(doc.windows)) {
+      return rawDump(container, doc, '// section did not match the digest schema');
+    }
+
+    doc.windows.forEach(function (win) {
+      try {
+        var el = renderWindow(container, win);
+        if (el && doc.section) {
+          el.dataset.section = doc.section;
+        }
+      } catch (err) {
+        if (global.console) { global.console.warn('digest: window fell back to plain text', err); }
+        rawDump(container, win, '// window failed to render: ' + err);
+      }
+    });
+  }
+
+  var DigestRender = {
+    render: function (container, input, options) {
+      options = options || {};
+      container.innerHTML = '';
+      container.classList.add('digest-canvas');
+
+      var parsed = parse(input);
+
+      if (parsed && parsed.__unparseable !== undefined) {
+        rawDump(container, parsed.__unparseable, '// digest JSON did not parse');
+        return;
+      }
+
+      var sections = toSectionList(parsed, options.detailLevel || 'full');
+
+      if (!sections.length) {
+        rawDump(container, parsed, '// no digest sections found in this document');
+        return;
+      }
+
+      sections.forEach(function (doc) {
+        try {
+          renderSection(container, doc);
+        } catch (err) {
+          if (global.console) { global.console.warn('digest: section fell back to plain text', err); }
+          rawDump(container, doc, '// section failed to render: ' + err);
+        }
+      });
+    },
+
+    // Convenience used by both templates: fetch, render, and put the failure on
+    // screen rather than only in the console if the fetch itself fails.
+    load: function (container, url, options) {
+      return global.fetch(url, { cache: 'no-store' })
+        .then(function (response) {
+          if (!response.ok) { throw new Error(response.status + ' ' + response.statusText); }
+          return response.text();
+        })
+        .then(function (text) {
+          DigestRender.render(container, text, options);
+        })
+        .catch(function (err) {
+          container.innerHTML = '';
+          container.classList.add('digest-canvas');
+          rawDump(container, String(err), '// could not load ' + url);
+        });
+    }
+  };
+
+  global.DigestRender = DigestRender;
+})(window);
diff --git a/digest-engine/render/digest-canvas-sdk/window-chrome.js b/digest-engine/render/digest-canvas-sdk/window-chrome.js
new file mode 100644
index 0000000..1e02e05
--- /dev/null
+++ b/digest-engine/render/digest-canvas-sdk/window-chrome.js
@@ -0,0 +1,117 @@
+/*
+ * DigestWindow — floating panel chrome for the digest canvas.
+ *
+ * Windows look like windows (title bar, rounded corners, shadow, glow accent)
+ * but are not actually draggable. The canvas is read on a wall-mounted kiosk and
+ * inside a Home Assistant iframe card; neither has a pointer doing window
+ * management, and real drag-and-drop would only add state that nothing persists.
+ *
+ * x/y/w/h are optional. Given, the window is absolutely positioned (percentages
+ * of the canvas) — that is the escape hatch for a deliberately composed layout.
+ * Omitted, the window participates in the canvas's normal flow/grid layout, which
+ * is what render.js does by default so the layout survives any window count the
+ * LLM decides to emit.
+ */
+
+(function (global) {
+  'use strict';
+
+  var counter = 0;
+
+  function escapeHtml(text) {
+    return String(text)
+      .replace(/&/g, '&')
+      .replace(//g, '>');
+  }
+
+  // Intentionally tiny: bold, italic, inline code and paragraph breaks. The
+  // schema calls content "markdown-ish", and pulling in a markdown parser for
+  // three inline forms would violate this project's no-unnecessary-deps rule.
+  function renderInline(text) {
+    return escapeHtml(text)
+      .replace(/`([^`]+)`/g, '$1')
+      .replace(/\*\*([^*]+)\*\*/g, '$1')
+      .replace(/(^|[\s(])\*([^*]+)\*/g, '$1$2');
+  }
+
+  function renderContent(content) {
+    var body = document.createElement('div');
+    body.className = 'digest-window-body';
+
+    if (Array.isArray(content)) {
+      var list = document.createElement('ul');
+      list.className = 'digest-window-list';
+      content.forEach(function (item) {
+        var li = document.createElement('li');
+        li.innerHTML = renderInline(item);
+        list.appendChild(li);
+      });
+      body.appendChild(list);
+      return body;
+    }
+
+    // Duck-typed rather than `instanceof Node`: render.js hands the globe its
+    // mount point through here, and an identity check against a global that may
+    // not exist in every embedding context is a needless way to lose the globe.
+    if (content && typeof content.nodeType === 'number') {
+      body.appendChild(content);
+      return body;
+    }
+
+    String(content === undefined || content === null ? '' : content)
+      .split(/\n{2,}/)
+      .forEach(function (block) {
+        if (!block.trim()) { return; }
+        var p = document.createElement('p');
+        p.innerHTML = renderInline(block).replace(/\n/g, '
'); + body.appendChild(p); + }); + + return body; + } + + var DigestWindow = { + open: function (options) { + options = options || {}; + + var container = options.container || document.body; + + var el = document.createElement('section'); + el.className = 'digest-window'; + el.id = options.id || ('digest-window-' + (++counter)); + if (options.variant) { + el.classList.add('digest-window-' + options.variant); + } + + var bar = document.createElement('header'); + bar.className = 'digest-window-titlebar'; + + var dots = document.createElement('span'); + dots.className = 'digest-window-dots'; + dots.setAttribute('aria-hidden', 'true'); + bar.appendChild(dots); + + var title = document.createElement('h2'); + title.className = 'digest-window-title'; + title.textContent = options.title || ''; + bar.appendChild(title); + + el.appendChild(bar); + el.appendChild(renderContent(options.content)); + + if (typeof options.x === 'number' && typeof options.y === 'number') { + el.classList.add('digest-window-positioned'); + el.style.left = options.x + '%'; + el.style.top = options.y + '%'; + } + if (typeof options.w === 'number') { el.style.width = options.w + '%'; } + if (typeof options.h === 'number') { el.style.height = options.h + '%'; } + + container.appendChild(el); + return el; + } + }; + + global.DigestWindow = DigestWindow; +})(window); diff --git a/digest-engine/render/templates/compact.html b/digest-engine/render/templates/compact.html new file mode 100644 index 0000000..e2f0b1c --- /dev/null +++ b/digest-engine/render/templates/compact.html @@ -0,0 +1,65 @@ + + + + + +Digest — compact + + + + +
+ + + + + + + diff --git a/digest-engine/render/templates/full.html b/digest-engine/render/templates/full.html new file mode 100644 index 0000000..c81c6c0 --- /dev/null +++ b/digest-engine/render/templates/full.html @@ -0,0 +1,72 @@ + + + + + +Digest + + + + +
+ + + + + + + diff --git a/digest-engine/requirements.txt b/digest-engine/requirements.txt new file mode 100644 index 0000000..a71b911 --- /dev/null +++ b/digest-engine/requirements.txt @@ -0,0 +1,9 @@ +requests>=2.31 +imapclient>=3.0 +feedparser>=6.0 +telethon>=1.36 +discord.py>=2.3 +websocket-client>=1.7 +caldav>=2.0 +icalendar>=5.0 +paho-mqtt>=1.6 diff --git a/digest-engine/run.py b/digest-engine/run.py new file mode 100644 index 0000000..3c71f19 --- /dev/null +++ b/digest-engine/run.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""digest-engine entrypoint — one digest run, then exit. + +Invoked as `docker compose run --rm digest-engine` by the smart-home-digest.timer +systemd timer, 4x/day. It is deliberately a oneshot, not a daemon: scheduling +lives in systemd, exactly like the restic backup job in +hosts/container-host/scripts/setup-container-host.sh. + +Every ingestion source is independently toggleable and independently fallible. A +source that is disabled, misconfigured, or simply broken (a stale Telegram +session, a signal-cli container that is down) logs a warning and contributes an +empty list — it must never take down the rest of the run, because a digest with +two of three sections is worth far more than no digest at all. + +Everything here is read-only. See docs/project-plan.md Phase 12 step 8. + +WHICH RUN IS THIS? +------------------ +One feature — the evening recipe suggestion in synth/prompts/household.md — only +applies to one of the four daily runs. The slot is derived here from the local +wall clock rather than passed in by the caller: the systemd unit installed by +hosts/container-host/scripts/setup-container-host.sh runs a bare +`docker compose run --rm digest-engine` with no arguments, and a manual run uses +exactly the same command, so anything argument- or unit-based would have to be +threaded through both and would silently do the wrong thing on a hand-run digest. +The container already has the host's timezone (`TZ` plus a bind-mounted +/etc/localtime), which is the same clock systemd's `OnCalendar` fires against. + +The run is attributed to the most recent DIGEST_SCHEDULE slot at or before now, +not to an exact hour match, because the timer is `Persistent=true`: a host that +was asleep at 18:00 fires the run late, and an exact match would drop the evening +feature precisely on the days the digest is read late. +""" + +import json +import logging +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +from ingest import ( + caldav as caldav_ingest, + discord_ingest, + email_imap, + financial, + flight_traffic, + grocy, + naval_traffic, + news_rss, + opnsense_ids, + signal_ingest, + telegram_ingest, + whatsapp_ingest, +) +from synth import llm_client +import viewed_tracker + +LOG = logging.getLogger("digest") + +# (env toggle, context key, module) — order is the order they run in. +SOURCES = ( + ("ENABLE_EMAIL_INGEST", "email", email_imap), + ("ENABLE_SIGNAL_INGEST", "signal", signal_ingest), + ("ENABLE_TELEGRAM_INGEST", "telegram", telegram_ingest), + ("ENABLE_DISCORD_INGEST", "discord", discord_ingest), + ("ENABLE_WHATSAPP_INGEST", "whatsapp", whatsapp_ingest), + ("ENABLE_NEWS_INGEST", "news", news_rss), + ("ENABLE_FINANCIAL_INGEST", "financial", financial), + ("ENABLE_FLIGHT_TRAFFIC_INGEST", "flight_traffic", flight_traffic), + ("ENABLE_NAVAL_TRAFFIC_INGEST", "naval_traffic", naval_traffic), + ("ENABLE_OPNSENSE_IDS_INGEST", "opnsense_ids", opnsense_ids), + ("ENABLE_CALDAV_INGEST", "calendar", caldav_ingest), + ("ENABLE_GROCY_INGEST", "grocy", grocy), +) + +PERSONAL_SOURCES = ("email", "signal", "telegram", "discord", "whatsapp") +POLITICAL_SOURCES = ("news", "financial", "email", "flight_traffic", "naval_traffic") + +DEFAULT_SCHEDULE = "00,06,12,18" +DEFAULT_EVENING_HOUR = 18 + + +def env_flag(name, default="false"): + return os.environ.get(name, default).strip().lower() == "true" + + +def schedule_hours(): + hours = sorted( + {int(part.strip()) for part in os.environ.get("DIGEST_SCHEDULE", DEFAULT_SCHEDULE).split(",") + if part.strip().isdigit() and 0 <= int(part.strip()) <= 23} + ) + if not hours: + LOG.warning("DIGEST_SCHEDULE is unusable, falling back to %s", DEFAULT_SCHEDULE) + return [int(part) for part in DEFAULT_SCHEDULE.split(",")] + return hours + + +def slot_hour(now_local, hours): + earlier = [hour for hour in hours if hour <= now_local.hour] + # Before the first slot of the day the run still belongs to yesterday's last one. + return earlier[-1] if earlier else hours[-1] + + +def resolve_slot(): + now_local = datetime.now().astimezone() + hours = schedule_hours() + try: + evening_hour = int(os.environ.get("DIGEST_EVENING_HOUR", DEFAULT_EVENING_HOUR)) + except ValueError: + # Unlike an ingestion module, this runs outside collect()'s backstop, and a + # typo in one env var must not take down a whole digest. + LOG.warning("DIGEST_EVENING_HOUR is not a number, falling back to %s", DEFAULT_EVENING_HOUR) + evening_hour = DEFAULT_EVENING_HOUR + + if evening_hour not in hours: + LOG.warning( + "DIGEST_EVENING_HOUR=%s is not one of the DIGEST_SCHEDULE slots %s, " + "so no run will ever be the evening one", + evening_hour, + hours, + ) + + current = slot_hour(now_local, hours) + is_evening = current == evening_hour or env_flag("DIGEST_FORCE_EVENING") + LOG.info("run attributed to the %02d:00 slot (evening run: %s)", current, is_evening) + return current, evening_hour, is_evening + + +def collect(lookback_hours): + collected = {} + for toggle, key, module in SOURCES: + if not env_flag(toggle): + LOG.info("%s is not enabled, skipping %s ingestion", toggle, key) + collected[key] = [] + continue + try: + collected[key] = module.fetch(lookback_hours) or [] + except Exception: + # Modules already swallow their own failures; this is the backstop for + # anything they miss (an import-time error, an unexpected exception type). + LOG.warning("%s ingestion raised, continuing without it", key, exc_info=True) + collected[key] = [] + return collected + + +def load_previous_run(output_dir): + """Best-effort read of the run currently pointed at by output/latest — the + candidate for merging if it turns out not to have been viewed. Any failure (first + run ever, corrupt JSON, missing file) is silently "no previous run", which just + means no merge is attempted, never a crash.""" + try: + return json.loads((output_dir / "latest" / "digest.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def should_merge(previous_run, previous_viewed_at): + if previous_run is None: + return False + # None (unknown) is handled identically to "viewed" — see viewed_tracker's + # last_viewed_at() docstring for why treating unknown as unviewed is the riskier + # default, not the safer one. + if previous_viewed_at is None: + return False + return previous_viewed_at < previous_run.get("generated_at", "") + + +def previous_section_document(previous_run, section): + """The previous run's own rendered document for one section, at `full` detail — + the richest version, since what gets folded in is content, not layout, and the + merge instruction in synth/llm_client.py asks the model to write fresh prose at + whatever detail_level this pass actually is, not to reuse this verbatim.""" + if not previous_run: + return None + for doc in (previous_run.get("sections") or {}).get("full") or []: + if isinstance(doc, dict) and doc.get("section") == section: + return doc + return None + + +def build_section_contexts(collected, lookback_hours, is_evening_run, previous_run=None): + # The full pantry is only ever needed to work out what the evening recipe still + # requires. On the other three runs it is a few hundred lines of prompt that + # buys nothing, so it is dropped rather than sent and then ignored. + grocy_entries = [ + entry + for entry in collected.get("grocy", []) + if is_evening_run or entry.get("category") != "in_stock" + ] + + contexts = { + "personal": { + "lookback_hours": lookback_hours, + "messages": {key: collected.get(key, []) for key in PERSONAL_SOURCES}, + }, + "political": { + "lookback_hours": lookback_hours, + "news": collected.get("news", []), + "financial": collected.get("financial", []), + "mail": collected.get("email", []), + # Extra evidence for the existing political synthesis, not a fourth + # window type — see the traffic-data section of synth/prompts/political.md. + "flight_traffic": collected.get("flight_traffic", []), + "naval_traffic": collected.get("naval_traffic", []), + }, + # Both keys stay present even when their source is off or broken, so the + # prompt sees the shape it is promised and says "nothing scheduled" instead + # of hallucinating an event. + "household": { + "lookback_hours": lookback_hours, + "calendar": collected.get("calendar", []), + "grocy": grocy_entries, + # Drives the evening-only recipe/shopping-list section of + # synth/prompts/household.md. Nothing is ever written back to Grocy. + "is_evening_run": is_evening_run, + # Home network status sits with the household, not the political + # section — it is a "something in this house needs your attention" + # item. See the network-security section of synth/prompts/household.md. + "network_security": collected.get("opnsense_ids", []), + }, + } + + if previous_run is not None: + for section, context in contexts.items(): + doc = previous_section_document(previous_run, section) + if doc: + context["previous_unviewed_digest"] = { + "generated_at": doc.get("generated_at"), + "windows": doc.get("windows", []), + "narration": doc.get("narration", ""), + } + + return contexts + + +def write_output(output_dir, run_id, context, documents): + run_dir = output_dir / run_id + run_dir.mkdir(parents=True, exist_ok=True) + + # Persisted for the Phase 12 step 7 follow-up voice Q&A: a spoken follow-up + # re-queries Ollama against this cached context instead of re-ingesting. + (run_dir / "context.json").write_text( + json.dumps(context, indent=2, ensure_ascii=False, default=str), encoding="utf-8" + ) + + digest = { + "run_id": run_id, + "generated_at": context["generated_at"], + "sections": documents, + } + payload = json.dumps(digest, indent=2, ensure_ascii=False, default=str) + (run_dir / "digest.json").write_text(payload, encoding="utf-8") + + # digest-web serves output/ read-only and has no idea which run is newest, so + # the pointer has to live in the served tree itself. latest.json holds the whole + # digest so a template needs exactly one fetch; the `latest` symlink is for + # humans poking around the volume. + (output_dir / "latest.json").write_text(payload, encoding="utf-8") + + symlink = output_dir / "latest" + try: + if symlink.is_symlink() or symlink.exists(): + symlink.unlink() + symlink.symlink_to(run_id, target_is_directory=True) + except OSError: + LOG.warning("could not update the output/latest symlink", exc_info=True) + + return run_dir + + +def main(): + logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", + ) + + lookback_hours = float(os.environ.get("DIGEST_LOOKBACK_HOURS", "6")) + output_dir = Path(os.environ.get("DIGEST_OUTPUT_DIR", "/output")) + started_at = datetime.now(timezone.utc) + run_id = started_at.strftime("%Y%m%dT%H%M%SZ") + + LOG.info("digest run %s starting (lookback %sh)", run_id, lookback_hours) + + current_slot, evening_hour, is_evening_run = resolve_slot() + collected = collect(lookback_hours) + + previous_run = load_previous_run(output_dir) + previous_viewed_at = viewed_tracker.last_viewed_at() + merge_previous = should_merge(previous_run, previous_viewed_at) + LOG.info( + "previous run viewed_at=%s, previous run generated_at=%s -> merging: %s", + previous_viewed_at, + previous_run.get("generated_at") if previous_run else None, + merge_previous, + ) + + context = { + "run_id": run_id, + "generated_at": started_at.replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "lookback_hours": lookback_hours, + "slot_hour": current_slot, + "evening_hour": evening_hour, + "is_evening_run": is_evening_run, + "enabled_sources": [key for toggle, key, _ in SOURCES if env_flag(toggle)], + "item_counts": {key: len(items) for key, items in collected.items()}, + "sources": collected, + "merged_unviewed_previous_run": merge_previous, + } + + section_contexts = build_section_contexts( + collected, lookback_hours, is_evening_run, + previous_run=previous_run if merge_previous else None, + ) + documents = llm_client.generate_all(section_contexts) + + run_dir = write_output(output_dir, run_id, context, documents) + + LOG.info( + "digest run %s complete: %s -> %s", + run_id, + context["item_counts"], + run_dir, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/digest-engine/synth/__init__.py b/digest-engine/synth/__init__.py new file mode 100644 index 0000000..a8de372 --- /dev/null +++ b/digest-engine/synth/__init__.py @@ -0,0 +1 @@ +"""LLM synthesis for digest-engine — prompt templates plus the Ollama client.""" diff --git a/digest-engine/synth/llm_client.py b/digest-engine/synth/llm_client.py new file mode 100644 index 0000000..f3d5eb4 --- /dev/null +++ b/digest-engine/synth/llm_client.py @@ -0,0 +1,243 @@ +"""Ollama client for digest synthesis. + +Talks to the existing Phase 3 Ollama host over plain HTTP (`/api/generate` with +`"format": "json"`, Ollama's structured-output mode) — no SDK, matching this +project's preference for not pulling a dependency to make one POST. + +DIGEST JSON SCHEMA +------------------ +Every call returns exactly one document of this shape. The same schema is +restated in each prompt file under synth/prompts/ so the model sees it verbatim; +keep the two in sync when changing either. + + { + "generated_at": "2026-07-28T12:00:00Z", + "detail_level": "compact" | "full", + "section": "personal" | "political" | "household", + "windows": [ + { + "id": "string, unique within this section", + "title": "string", + "kind": "text" | "list" | "globe", + "content": "markdown-ish string for kind=text, or an array of strings for kind=list", + "globe_markers": [ + { + "lat": 0.0, + "lon": 0.0, + "label": "string", + "icon": "star|hammer-sickle|default", + "color": "#hex", + "glow": true + } + ] + } + ], + "narration": "a short plain-text script suitable for TTS narration of this section, 2-4 sentences" + } + +`globe_markers` is only present (and non-empty) on `kind: "globe"` windows, which +in practice only the political section produces. + +DETAIL LEVELS +------------- +Each section is generated twice per run, once at `compact` and once at `full` +(6 calls total), rather than generating `full` once and truncating it client-side: +truncation gives you the first N windows of a document written to be expansive, +so a "compact" window can still hold a 400-word blob that overflows the HA +Lovelace iframe card, whereas a second pass yields prose actually written to be +terse. The cost is 3 extra calls against a local, self-hosted Ollama on a batch +timer — no per-token bill and no latency anyone is waiting on — so correctness of +the compact rendering wins. Both passes reuse one ingestion pass and one assembled +context, which is what docs/project-plan.md means by "without needing two +independent generation passes". +""" + +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path + +import requests + +LOG = logging.getLogger(__name__) + +SECTIONS = ("personal", "political", "household") +DETAIL_LEVELS = ("compact", "full") + +PROMPT_DIR = Path(__file__).parent / "prompts" + +DETAIL_INSTRUCTIONS = { + "compact": "detail_level: compact — keep to 1-2 windows, terse", + "full": "detail_level: full — feel free to compose 3-6 windows with more depth", +} + + +def _now_iso(): + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _fallback_document(section, detail_level, text, title="Digest (plain text fallback)"): + """A schema-valid document wrapping whatever the model actually said. + + The render layer already degrades gracefully on malformed input, but emitting + a valid document here means the failure shows up as one readable window + instead of a
 dump of a stack trace.
+    """
+    return {
+        "generated_at": _now_iso(),
+        "detail_level": detail_level,
+        "section": section,
+        "windows": [
+            {
+                "id": f"{section}-fallback",
+                "title": title,
+                "kind": "text",
+                "content": text or "No digest content was produced for this section.",
+            }
+        ],
+        "narration": "",
+        "degraded": True,
+    }
+
+
+def _coerce_document(raw, section, detail_level):
+    if not isinstance(raw, dict):
+        raise ValueError("model output was not a JSON object")
+
+    windows = []
+    for index, window in enumerate(raw.get("windows") or []):
+        if not isinstance(window, dict):
+            continue
+        kind = window.get("kind") if window.get("kind") in ("text", "list", "globe") else "text"
+        coerced = {
+            "id": str(window.get("id") or f"{section}-{index}"),
+            "title": str(window.get("title") or ""),
+            "kind": kind,
+            "content": window.get("content", ""),
+        }
+        if kind == "globe":
+            markers = []
+            for marker in window.get("globe_markers") or []:
+                if not isinstance(marker, dict):
+                    continue
+                try:
+                    markers.append(
+                        {
+                            "lat": float(marker.get("lat", 0.0)),
+                            "lon": float(marker.get("lon", 0.0)),
+                            "label": str(marker.get("label") or ""),
+                            "icon": str(marker.get("icon") or "default"),
+                            "color": str(marker.get("color") or "#8ab4ff"),
+                            "glow": bool(marker.get("glow", False)),
+                        }
+                    )
+                except (TypeError, ValueError):
+                    continue
+            coerced["globe_markers"] = markers
+        windows.append(coerced)
+
+    if not windows:
+        raise ValueError("model output contained no usable windows")
+
+    return {
+        "generated_at": raw.get("generated_at") or _now_iso(),
+        "detail_level": detail_level,
+        "section": section,
+        "windows": windows,
+        "narration": str(raw.get("narration") or ""),
+    }
+
+
+def load_prompt(section):
+    return (PROMPT_DIR / f"{section}.md").read_text(encoding="utf-8")
+
+
+def _merge_instruction(context):
+    """Added centrally here rather than in each of the three prompt files: whether the
+    previous run went unviewed is run-orchestration state (see ../viewed_tracker.py and
+    run.py's should_merge()), identical in wording for every section, and unrelated to
+    each section's own analytical framing — duplicating it three times in prose that's
+    supposed to stay in sync would be the actual maintenance burden, not this.
+    """
+    if not context.get("previous_unviewed_digest"):
+        return ""
+    return (
+        "\n## Unviewed previous digest\n\n"
+        "`previous_unviewed_digest` in the context below is this section's own "
+        "content from the last run — and nobody has looked at it yet: no thin client "
+        "has shown a digest since it was generated (see its `generated_at`). Combine "
+        "it with this run's new material into ONE digest, not two: carry forward "
+        "whatever in it is still current, drop whatever this run's material has "
+        "superseded, corrected, or made irrelevant, and never state the same point "
+        "twice. Do not mention that a merge happened, and do not treat the previous "
+        "narration as something to read verbatim — write one narration for the "
+        "combined result.\n"
+    )
+
+
+def build_prompt(section, detail_level, context):
+    return (
+        f"{load_prompt(section)}\n"
+        f"{_merge_instruction(context)}\n"
+        "## Run context\n\n"
+        "```json\n"
+        f"{json.dumps(context, indent=2, ensure_ascii=False, default=str)}\n"
+        "```\n\n"
+        f"{DETAIL_INSTRUCTIONS[detail_level]}\n"
+        f"current time (UTC): {_now_iso()}\n"
+    )
+
+
+def generate_section(section, detail_level, context):
+    host = os.environ.get("OLLAMA_HOST", "http://llm-host:11434").rstrip("/")
+    model = os.environ.get("OLLAMA_MODEL", "qwen2.5:14b-instruct")
+    timeout = float(os.environ.get("OLLAMA_TIMEOUT", "600"))
+
+    payload = {
+        "model": model,
+        "prompt": build_prompt(section, detail_level, context),
+        "stream": False,
+        "format": "json",
+        "options": {"temperature": float(os.environ.get("OLLAMA_TEMPERATURE", "0.4"))},
+    }
+
+    try:
+        response = requests.post(f"{host}/api/generate", json=payload, timeout=timeout)
+        response.raise_for_status()
+        text = response.json().get("response", "")
+    except Exception:
+        LOG.warning(
+            "synth: Ollama call failed for %s/%s, emitting a degraded document",
+            section,
+            detail_level,
+            exc_info=True,
+        )
+        return _fallback_document(
+            section,
+            detail_level,
+            f"The {section} digest could not be generated: the LLM host at {host} did not respond.",
+            title=f"{section.title()} — unavailable",
+        )
+
+    try:
+        return _coerce_document(json.loads(text), section, detail_level)
+    except Exception:
+        LOG.warning(
+            "synth: %s/%s output did not match the digest schema, falling back to plain text",
+            section,
+            detail_level,
+            exc_info=True,
+        )
+        return _fallback_document(section, detail_level, text.strip())
+
+
+def generate_all(section_contexts):
+    documents = {level: [] for level in DETAIL_LEVELS}
+    for detail_level in DETAIL_LEVELS:
+        for section in SECTIONS:
+            LOG.info("synth: generating %s/%s", section, detail_level)
+            documents[detail_level].append(
+                generate_section(section, detail_level, section_contexts.get(section, {}))
+            )
+    return documents
diff --git a/digest-engine/synth/prompts/household.md b/digest-engine/synth/prompts/household.md
new file mode 100644
index 0000000..6a42c4b
--- /dev/null
+++ b/digest-engine/synth/prompts/household.md
@@ -0,0 +1,140 @@
+# Household digest
+
+You are the household section of a household digest that is generated four times
+a day. You are given the household calendar (read from Nextcloud over CalDAV) and
+household inventory/chore state from Grocy — upcoming events, stock that is low
+or expiring, chores and battery levels that are due. Some runs also carry a home
+network security summary — see "Home network security" below for the narrow way
+that may be used. One run a day is the evening run — see "Evening recipe and
+shopping list", which applies to that run and no other.
+
+`calendar` entries are tagged `"category": "calendar_event"` and carry `summary`,
+`start`, `end`, `all_day`, `location` and `recurring`. Times are UTC (`Z`) unless
+`all_day` is true, in which case only a date is given.
+
+`grocy` entries are tagged with a `category`:
+
+- `expiring_stock` — something in the house is running out of time. `status` is
+  `due_soon` (within the configured window), `overdue` (past its best-before
+  date, usually still fine) or `expired` (past a hard expiration date). Read
+  `days_until_due`: negative means it has already passed.
+- `missing_stock` — below its minimum stock level; `amount_missing` says by how
+  much.
+- `chore` and `battery` — due or nearly due, with `days_until_due`.
+- `shopping_list` — already on the household's Grocy shopping list.
+- `in_stock` — the pantry. Present on the evening run only, and only so you can
+  work out what a recipe still needs. **Never enumerate the pantry in the
+  digest**; nobody wants their groceries read back to them.
+
+Your job: tell the household what is coming up and what needs doing. Lead with
+anything time-critical in the next 24 hours. Say which items are running out or
+expiring soon and roughly when. Keep it practical — this is read on a wall
+display and spoken aloud in a kitchen, not filed.
+
+If a part of the context is empty (no calendar events, no Grocy data), say so
+plainly in one short line rather than padding the section out or inventing
+entries. Never invent an event, an item, or a due date that is not in the
+context below.
+
+You are read-only. You never create, move or delete a calendar event, never
+consume or restock anything in Grocy, and never propose that the system do so on
+its own — at most you can tell the user that something needs their attention.
+
+## Home network security
+
+Some runs carry a `network_security` entry tagged `"category":
+"network_security"`: a summary of the Suricata intrusion-detection alerts the
+household firewall raised during this digest window. Treat it as one small
+household item — "is anything wrong with the home network" — not a section of its
+own, and give it at most a couple of lines.
+
+- If `alert_count` is 0 and `ids_status` is `"running"`, say the network was
+  quiet in one short clause and move on. Do not pad it.
+- If `ids_status` is anything other than `"running"`, say the intrusion detection
+  was not running, so there is nothing to report — never present that as a quiet
+  network.
+- When there are alerts, lead with what a person would act on: which local
+  device (`top_local_hosts`) and which signature (`top_signatures`), and whether
+  the traffic was blocked or only alerted on (`actions` / `alerts_by_action` —
+  `"blocked"` means the firewall already stopped it, `"allowed"` means it did
+  not).
+- Respect the `caveat` field. These are signature matches, not confirmed
+  compromise; false positives are routine, severity is not available to you, and
+  you must never call a device infected or compromised on this evidence. Say what
+  fired and let the user judge. Never state that the network is safe or clean.
+- If `window_truncated` is true, say the counts are a lower bound.
+- If `packet_capture_reference` is present, you may mention in one clause that
+  raw captures are available at that location. You have not read them.
+
+## Evening recipe and shopping list
+
+The context carries `is_evening_run`. **If it is false, this whole section does
+not apply**: emit no recipe, no shopping list, and no mention of either. Do not
+explain that a recipe was omitted.
+
+If `is_evening_run` is true and there is at least one `expiring_stock` entry,
+add:
+
+- one `kind: "text"` window with id `household-recipe` — a single dish that uses
+  the expiring items as its main ingredients, with a short method someone can
+  actually follow after a day's work. Say which expiring items it uses and how
+  much of each.
+- one `kind: "list"` window with id `household-shopping-list` — only the
+  ingredients the recipe needs that are **not** already covered by the `in_stock`
+  entries and **not** already on a `shopping_list` entry. If the recipe needs
+  nothing else, say so in the recipe window and omit this window entirely.
+
+Rules for it:
+
+- Build the recipe around `due_soon` and `overdue` items. Do **not** build a
+  recipe around an `expired` item — if you mention one at all, say it should be
+  checked or thrown out.
+- Suggest one dish, not three. Prefer something that uses several expiring items
+  at once over something that uses one.
+- Only claim an ingredient is in the house if it is in the `in_stock` entries.
+  Everything else goes on the shopping list, and store-cupboard staples are no
+  exception — you cannot see what is not in the context.
+- If there are no `expiring_stock` entries, emit neither window. Do not invent a
+  reason to cook.
+- **This is a suggestion for a person to act on by hand.** Nothing here is added
+  to Grocy's shopping list, no stock is consumed, and no order is placed. Never
+  say or imply that the shopping list has been saved, added or sent anywhere.
+
+At `detail_level: compact`, fold the recipe and its shopping list into the single
+`household-recipe` window and keep the rest of the section to one other window.
+
+## Output
+
+Output **only** a single JSON object matching this schema — no prose before or
+after it, no markdown code fence:
+
+```json
+{
+  "generated_at": "2026-07-28T12:00:00Z",
+  "detail_level": "compact" | "full",
+  "section": "household",
+  "windows": [
+    {
+      "id": "string, unique within this section",
+      "title": "string",
+      "kind": "text" | "list",
+      "content": "markdown-ish string for kind=text, or an array of strings for kind=list"
+    }
+  ],
+  "narration": "a short plain-text script suitable for TTS narration of this section, 2-4 sentences"
+}
+```
+
+Rules:
+
+- `section` must be exactly `"household"`.
+- `detail_level` must echo the `detail_level` line given at the end of the context.
+- Do **not** emit any window with `kind: "globe"` and do **not** emit
+  `globe_markers` in this section. The globe belongs to the political section.
+- `id` must be unique within this section, lowercase, hyphenated (e.g.
+  `household-calendar`, `household-stock`).
+- Calendar entries and shopping/stock items are enumerable — use `kind: "list"`
+  with an array of short strings, each leading with the time or the item name.
+- `narration` is spoken aloud by a TTS voice, so no markdown, no URLs, no emoji.
+- If you cannot produce valid JSON matching this schema, output a single
+  `kind: "text"` window with your best-effort plain-text summary instead.
diff --git a/digest-engine/synth/prompts/personal.md b/digest-engine/synth/prompts/personal.md
new file mode 100644
index 0000000..d5c2825
--- /dev/null
+++ b/digest-engine/synth/prompts/personal.md
@@ -0,0 +1,54 @@
+# Personal digest
+
+You are the personal-life section of a household digest that is generated four
+times a day. You are given raw excerpts of the user's own mail and messages from
+the last few hours (email, Signal, Telegram, Discord, WhatsApp — whichever of
+those are enabled).
+
+Your job: tell the user what is actually new in their personal life since the
+last digest. Group related threads together instead of listing messages one by
+one. Say who is waiting on a reply, what has a date attached, and what can be
+ignored. Skip newsletters, receipts, automated notifications and marketing unless
+something in them genuinely needs the user's attention. Never invent a message,
+a sender, or a commitment that is not in the context below.
+
+You are read-only. You are summarising, not replying, not drafting replies, and
+not proposing that anything be sent. Do not suggest actions that mutate any
+account.
+
+## Output
+
+Output **only** a single JSON object matching this schema — no prose before or
+after it, no markdown code fence:
+
+```json
+{
+  "generated_at": "2026-07-28T12:00:00Z",
+  "detail_level": "compact" | "full",
+  "section": "personal",
+  "windows": [
+    {
+      "id": "string, unique within this section",
+      "title": "string",
+      "kind": "text" | "list",
+      "content": "markdown-ish string for kind=text, or an array of strings for kind=list"
+    }
+  ],
+  "narration": "a short plain-text script suitable for TTS narration of this section, 2-4 sentences"
+}
+```
+
+Rules:
+
+- `section` must be exactly `"personal"`.
+- `detail_level` must echo the `detail_level` line given at the end of the context.
+- Do **not** emit any window with `kind: "globe"` and do **not** emit
+  `globe_markers` in this section. The globe belongs to the political section.
+- `id` must be unique within this section, lowercase, hyphenated (e.g.
+  `personal-threads`).
+- Use `kind: "list"` with an array of short strings for anything enumerable
+  (people awaiting a reply, upcoming personal commitments); use `kind: "text"`
+  with a markdown-ish string for anything narrative.
+- `narration` is spoken aloud by a TTS voice, so no markdown, no URLs, no emoji.
+- If you cannot produce valid JSON matching this schema, output a single
+  `kind: "text"` window with your best-effort plain-text summary instead.
diff --git a/digest-engine/synth/prompts/political.md b/digest-engine/synth/prompts/political.md
new file mode 100644
index 0000000..84391e3
--- /dev/null
+++ b/digest-engine/synth/prompts/political.md
@@ -0,0 +1,145 @@
+# Political digest
+
+You are the political section of a household digest that is generated four times
+a day. You are given: entries from a curated set of news feeds, financial
+indicators (stock indices, oil, macro series such as unemployment), and the
+user's mail (from which you should use only the politically relevant items — for
+example union, campaign, tenants' association or party correspondence — and
+ignore everything personal, which is handled by a different section). Some runs
+also carry air- and naval-traffic samples — see "Traffic data" below for the
+narrow way those may be used.
+
+## Analytical framing
+
+Write from a Marxist, working-class standpoint. Entries in the context tagged
+`"category": "theory"` come from RCI (Revolutionary Communist International)
+publications and are your theoretical basis: lean on their analysis of the
+period, the class forces in play, and the direction of events. Do not simply
+restate their headlines back — use their framework to read everything else.
+
+Entries tagged `"category": "news_state_affiliated"` come from outlets that are
+organs of a state (e.g. Russian or Chinese state media), not independent press.
+Treat their factual claims about third parties with more scepticism than an
+independent outlet's, and treat their framing of their own state's actions as
+that state's self-presentation, worth noting as a data point ("Moscow/Beijing
+describes this as...") rather than reporting it as settled fact. Their
+reporting on labour/material conditions inside their own country can still be
+useful raw material — apply the same class analysis to it as to anything else,
+just don't launder state propaganda as neutral reporting.
+
+Entries tagged `"category": "osint_military"` come from defence and open-source
+intelligence outlets that track troop, fleet and air movements. Their reporting
+of *where forces are* is the useful part and is usually reliable. Their framing —
+which reads military spending as necessity and arms procurement as good news — is
+the trade press of the arms industry and should be treated as such, not adopted.
+
+Concretely, that means:
+
+- Ask whose interests a development serves, and which class is paying for it.
+- Treat financial indicators as social facts, not numbers. An index rally while
+  real wages stagnate is a transfer, not "good news". A crude oil move means
+  heating and transport costs for workers before it means anything else. Rising
+  unemployment means discipline on the shop floor and weaker bargaining power.
+  Always say what the movement means for working people, never just report the
+  figure.
+- Distinguish routine bourgeois-political manoeuvring from genuine movement of
+  the working class — strikes, occupations, mass demonstrations, general strikes,
+  revolutionary situations.
+- Be concrete and sober. No slogans in place of analysis, no invented events.
+  Everything you assert must trace back to something in the context.
+
+## Traffic data
+
+The context may also contain entries tagged `"category": "flight_traffic"`
+(aircraft counts and military-callsign matches inside a named region) and
+`"category": "naval_traffic"` (merchant-vessel counts and types inside a named
+maritime chokepoint). These are **supporting evidence for the analysis you are
+already writing**, never a subject in their own right. Do not give them a window
+of their own and do not report raw counts as if they were news.
+
+Use them only like this:
+
+- They are worth mentioning when they *corroborate something else in the
+  context*. An unusual concentration of military-callsign flights into a region
+  the news entries are already describing as escalating is a data point. The same
+  concentration with nothing else pointing at that region is not a story.
+- Merchant traffic is the more reliable of the two signals, and it reads
+  backwards: shipping *withdrawing* from a chokepoint is the finding, because it
+  arrives as freight rates, insurance costs and fuel prices, i.e. as prices
+  working people pay. Say that, not the vessel count.
+- Respect the `caveat` field on every such entry. Ordinary civil aviation and
+  ordinary commercial shipping are the overwhelming majority of what is in this
+  data and mean nothing militarily. Military aircraft matching is a callsign
+  heuristic, and warships routinely sail with AIS switched off — so a quiet
+  region is evidence of nothing, and you must never write that an area is calm
+  because these feeds are quiet.
+- One snapshot is not a trend. You have no previous run to compare against, so do
+  not describe anything here as rising, falling, massing or building up.
+
+If these entries add nothing to a story you are already telling, leave them out
+entirely. That is the expected outcome most days.
+
+You are read-only: you summarise and analyse, you never propose that the system
+send, post, or publish anything.
+
+## Output
+
+Output **only** a single JSON object matching this schema — no prose before or
+after it, no markdown code fence:
+
+```json
+{
+  "generated_at": "2026-07-28T12:00:00Z",
+  "detail_level": "compact" | "full",
+  "section": "political",
+  "windows": [
+    {
+      "id": "string, unique within this section",
+      "title": "string",
+      "kind": "text" | "list" | "globe",
+      "content": "markdown-ish string for kind=text, or an array of strings for kind=list",
+      "globe_markers": [
+        {
+          "lat": 0.0,
+          "lon": 0.0,
+          "label": "string",
+          "icon": "star|hammer-sickle|default",
+          "color": "#hex",
+          "glow": true
+        }
+      ]
+    }
+  ],
+  "narration": "a short plain-text script suitable for TTS narration of this section, 2-4 sentences"
+}
+```
+
+Rules:
+
+- `section` must be exactly `"political"`.
+- `detail_level` must echo the `detail_level` line given at the end of the context.
+- You must emit **at least one** window with `kind: "globe"`. Its
+  `globe_markers` array marks the notable political and economic situations
+  worldwide from this run's context, each at the real latitude/longitude of the
+  place it concerns.
+- Marker styling carries meaning, so apply it strictly:
+  - A revolutionary situation or a mass upsurge of the working class (general
+    strike, insurrection, mass occupation, revolutionary crisis) gets
+    `"icon": "hammer-sickle"`, `"color": "#e00000"`, `"glow": true`.
+  - A significant but non-revolutionary workers' struggle (a large sectoral
+    strike, a major union dispute) gets `"icon": "star"`, an amber colour such
+    as `"#f0a020"`, and `"glow": true`.
+  - Routine political-economic news (elections, central bank decisions,
+    diplomatic events, market moves) gets `"icon": "default"`, a muted colour
+    such as `"#7f9bb5"`, and `"glow": false`.
+  - Do not use the hammer-and-sickle marker for ordinary news. It marks
+    revolutionary situations only.
+- `label` on each marker is one short phrase, shown next to the marker on a
+  globe — keep it under about 40 characters.
+- `globe_markers` belongs only on `kind: "globe"` windows. Omit it everywhere else.
+- Alongside the globe, use `kind: "text"` windows for analysis and `kind: "list"`
+  windows for enumerable material (e.g. the financial indicators, each line
+  stating the move *and* what it means for working people).
+- `narration` is spoken aloud by a TTS voice, so no markdown, no URLs, no emoji.
+- If you cannot produce valid JSON matching this schema, output a single
+  `kind: "text"` window with your best-effort plain-text summary instead.
diff --git a/digest-engine/viewed_tracker.py b/digest-engine/viewed_tracker.py
new file mode 100644
index 0000000..477cdb6
--- /dev/null
+++ b/digest-engine/viewed_tracker.py
@@ -0,0 +1,99 @@
+"""Tracks whether the previous digest run was actually looked at.
+
+If it wasn't, the next run folds its content into the new one instead of quietly
+discarding it — see run.py's should_merge()/previous_section_document() and
+synth/llm_client.py's `previous_unviewed_digest` handling.
+
+The signal comes from Home Assistant/thinclient-agent over MQTT rather than a local
+file: the thing that knows whether a digest was actually shown is
+hosts/thin-client/agent/thinclient_agent/digest_canvas.py, which runs on a different
+physical machine (the thin client) from this one. MQTT is already this project's
+cross-host channel for exactly this kind of fact — the same broker Home Assistant and
+every thinclient-agent already use — so this reuses it rather than inventing a second
+one.
+
+This module only ever subscribes, never publishes. Publishing "viewed" is
+thinclient-agent's job (on_show_digest() in its main.py), triggered only by an actual
+full-canvas display (the "Show digest canvas" button or a voice-resolved "play my
+digest" request) — not by the compact HA-dashboard iframe view, which never reaches
+thinclient-agent at all and so cannot mark anything as viewed. That's deliberate: an
+iframe sitting open on someone's phone is a much weaker "was this looked at" signal
+than a thin client actually switching workspace and displaying it.
+"""
+
+import json
+import logging
+import os
+import threading
+
+LOG = logging.getLogger(__name__)
+
+VIEWED_TOPIC = "smarthome/digest/viewed"
+
+
+def last_viewed_at():
+    """ISO timestamp of the last time any thin client showed a digest, or None.
+
+    None covers every failure mode identically on purpose (no broker reachable,
+    nothing ever published, a malformed retained payload) — the caller treats
+    "unknown" the same as "viewed", the safe default. Treating unknown as "unviewed"
+    instead would mean a broken MQTT link silently glues every run onto the last one
+    forever; treating it as "viewed" just means at most one run's content is dropped
+    before someone notices the broker is unreachable, and OPNsense-style backstops
+    elsewhere in this codebase never take the riskier default either.
+    """
+    host = os.environ.get("MQTT_BROKER_HOST", "mosquitto")
+    port = int(os.environ.get("MQTT_BROKER_PORT", "1883"))
+    username = os.environ.get("MQTT_USERNAME") or None
+    password = os.environ.get("MQTT_PASSWORD") or None
+    wait_seconds = float(os.environ.get("MQTT_VIEWED_WAIT_SECONDS", "3"))
+
+    try:
+        import paho.mqtt.client as mqtt
+    except ImportError:
+        LOG.warning("paho-mqtt is not installed; cannot check whether the last digest was viewed")
+        return None
+
+    result = {"value": None}
+    received = threading.Event()
+
+    def on_message(_client, _userdata, message):
+        try:
+            payload = json.loads(message.payload.decode("utf-8"))
+            result["value"] = payload.get("viewed_at")
+        except (ValueError, AttributeError):
+            LOG.warning("malformed retained payload on %s: %r", VIEWED_TOPIC, message.payload)
+        received.set()
+
+    def on_connect(client, _userdata, _flags, rc, *_args):
+        if rc != 0:
+            LOG.warning("could not connect to MQTT broker %s:%s (rc=%s)", host, port, rc)
+            received.set()
+            return
+        client.subscribe(VIEWED_TOPIC, qos=1)
+
+    # Mirrors thinclient_agent.main.make_client's own VERSION1/VERSION2 handling —
+    # bookworm's python3-paho-mqtt is 1.6.x, but this runs in digest-engine's own
+    # container image, which may end up with a newer pip-installed paho-mqtt.
+    callback_api = getattr(mqtt, "CallbackAPIVersion", None)
+    client = mqtt.Client(callback_api.VERSION1) if callback_api is not None else mqtt.Client()
+    if username:
+        client.username_pw_set(username, password)
+    client.on_connect = on_connect
+    client.on_message = on_message
+
+    try:
+        client.connect(host, port, keepalive=10)
+        client.loop_start()
+        # A retained message arrives within a fraction of a second of subscribing, if
+        # one exists at all. The wait is bounded so an unreachable broker cannot stall
+        # the whole digest run — the same "never block on a remote service" rule
+        # thinclient-agent's own connect_async already applies to itself.
+        received.wait(wait_seconds)
+    except Exception:
+        LOG.warning("could not reach MQTT broker %s:%s", host, port, exc_info=True)
+    finally:
+        client.loop_stop()
+        client.disconnect()
+
+    return result["value"]
diff --git a/digest-engine/whatsapp-bridge/Dockerfile b/digest-engine/whatsapp-bridge/Dockerfile
new file mode 100644
index 0000000..8aa320a
--- /dev/null
+++ b/digest-engine/whatsapp-bridge/Dockerfile
@@ -0,0 +1,38 @@
+# whatsapp-bridge — headful Chromium under Xvfb, for read-only WhatsApp ingestion.
+#
+# Chromium comes from Debian's apt repo (PUPPETEER_SKIP_DOWNLOAD=true), not from
+# Puppeteer's own bundled download, for two reasons:
+#   1. The container host in this project is a Raspberry Pi 5 (arm64) — Puppeteer
+#      publishes no arm64 Chromium build, so the bundled download simply fails
+#      there. Debian's chromium package is built for both arm64 and amd64.
+#   2. It picks up Chromium security updates through apt like everything else on
+#      the host, instead of pinning whatever revision Puppeteer vendored.
+FROM node:20-bookworm-slim
+
+ENV PUPPETEER_SKIP_DOWNLOAD=true \
+    CHROMIUM_PATH=/usr/bin/chromium \
+    NODE_ENV=production
+
+# fonts-liberation + fonts-noto-color-emoji: without them Chromium renders WhatsApp
+# Web as tofu boxes, which also breaks the QR-code page on first login.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+      chromium \
+      xvfb \
+      ca-certificates \
+      fonts-liberation \
+      fonts-noto-color-emoji \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY package.json ./
+RUN npm install --omit=dev && npm cache clean --force
+
+COPY index.js ./
+
+VOLUME ["/data"]
+
+# xvfb-run is the whole mechanism that lets Chromium run headful (see the
+# headless: false comment in index.js) with no display attached to the host.
+# -a picks a free display number so a restart never collides with a stale lock.
+CMD ["xvfb-run", "-a", "--server-args=-screen 0 1280x1024x24", "node", "index.js"]
diff --git a/digest-engine/whatsapp-bridge/index.js b/digest-engine/whatsapp-bridge/index.js
new file mode 100644
index 0000000..b1fcecd
--- /dev/null
+++ b/digest-engine/whatsapp-bridge/index.js
@@ -0,0 +1,103 @@
+/*
+ * whatsapp-bridge — a real WhatsApp Web session that only ever reads.
+ *
+ * Long-lived sidecar to digest-engine. It keeps a logged-in web.whatsapp.com
+ * session open and appends every incoming message to /data/messages.jsonl, which
+ * digest-engine/ingest/whatsapp_ingest.py drains once per digest run.
+ *
+ * There is no send path in this file, and there must never be one: no reply(),
+ * no sendMessage(), no sendSeen(), no chat.markUnread(). Per docs/project-plan.md
+ * Phase 12 step 8, digest-engine has no mutation path anywhere by construction.
+ *
+ * READ digest-engine/README.md BEFORE ENABLING THIS. Automating a personal
+ * WhatsApp account carries a real ban risk, mitigated here but not eliminated.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const qrcode = require('qrcode-terminal');
+const { Client, LocalAuth } = require('whatsapp-web.js');
+
+const DATA_DIR = process.env.WHATSAPP_DATA_DIR || '/data';
+const MESSAGES_PATH = path.join(DATA_DIR, 'messages.jsonl');
+const AUTH_PATH = path.join(DATA_DIR, '.wwebjs_auth');
+
+fs.mkdirSync(DATA_DIR, { recursive: true });
+
+const client = new Client({
+  authStrategy: new LocalAuth({ dataPath: AUTH_PATH }),
+  puppeteer: {
+    /*
+     * headless: false is deliberate — do NOT "fix" this to true.
+     *
+     * WhatsApp's automation detection fingerprints headless Chrome specifically
+     * (navigator.webdriver, the HeadlessChrome UA token, missing GPU/permissions
+     * surface). Running a genuinely headful Chromium under Xvfb inside the
+     * container removes that signal entirely rather than trying to patch around
+     * it. The Dockerfile's `xvfb-run -a node index.js` is what supplies the
+     * virtual display that makes headful possible with no monitor attached.
+     */
+    headless: false,
+    executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
+    args: [
+      // Chromium's sandbox needs privileges this container deliberately does not
+      // have; the container itself is the isolation boundary here.
+      '--no-sandbox',
+      '--disable-setuid-sandbox',
+      // /dev/shm defaults to 64MB in Docker, which crashes Chromium's renderer.
+      '--disable-dev-shm-usage'
+    ]
+  }
+});
+
+client.on('qr', (qr) => {
+  console.log('--- Scan this QR code with WhatsApp -> Linked devices -> Link a device ---');
+  qrcode.generate(qr, { small: true });
+  console.log('--- (docker compose logs -f whatsapp-bridge) ---');
+});
+
+client.on('authenticated', () => {
+  console.log(`Authenticated. Session persisted under ${AUTH_PATH}.`);
+});
+
+client.on('auth_failure', (message) => {
+  console.error(`Authentication failed: ${message}`);
+  console.error(`Delete ${AUTH_PATH} and restart to re-scan the QR code.`);
+});
+
+client.on('ready', () => {
+  console.log(`Ready. Appending received messages to ${MESSAGES_PATH}.`);
+});
+
+client.on('disconnected', (reason) => {
+  console.error(`Disconnected: ${reason}. Exiting so the container restart policy reconnects.`);
+  process.exit(1);
+});
+
+// 'message' fires for incoming messages only ('message_create' would also fire
+// for our own outgoing ones, which are not what the digest summarises).
+client.on('message', async (message) => {
+  try {
+    const contact = await message.getContact();
+    const chat = await message.getChat();
+    const record = {
+      id: message.id ? message.id._serialized : null,
+      from: message.from,
+      from_name: contact ? (contact.pushname || contact.name || contact.number) : message.from,
+      chat: chat ? chat.name : null,
+      is_group: chat ? Boolean(chat.isGroup) : false,
+      timestamp: message.timestamp,
+      type: message.type,
+      body: message.body || ''
+    };
+    // appendFileSync opens/appends/closes per message, so whatsapp_ingest.py can
+    // rename the file out from under us mid-run without losing a partial write.
+    fs.appendFileSync(MESSAGES_PATH, JSON.stringify(record) + '\n', 'utf8');
+  } catch (err) {
+    console.error(`Could not record a message: ${err}`);
+  }
+});
+
+client.initialize();
diff --git a/digest-engine/whatsapp-bridge/package.json b/digest-engine/whatsapp-bridge/package.json
new file mode 100644
index 0000000..40476f0
--- /dev/null
+++ b/digest-engine/whatsapp-bridge/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "whatsapp-bridge",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Read-only WhatsApp Web sidecar for digest-engine — appends received messages to a JSON-lines file on a shared volume.",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js"
+  },
+  "dependencies": {
+    "whatsapp-web.js": "^1.26.0",
+    "qrcode-terminal": "^0.12.0"
+  }
+}
diff --git a/docs/project-plan.md b/docs/project-plan.md
index a365da1..0601da7 100644
--- a/docs/project-plan.md
+++ b/docs/project-plan.md
@@ -1,6 +1,6 @@
 # AI-Managed Smart Home — Full Build Plan (v2)
 
-Local-first, open-source stack: Home Assistant + RuView presence + Bermuda BLE identity + local LLM (Ollama) + Zigbee sensors/lighting + Frigate (peephole face recognition) + Grocy kitchen kiosk + Nextcloud calendar sync + Node-RED glue logic.
+Local-first, open-source stack: Home Assistant + RuView presence + Bermuda BLE identity + local LLM (Ollama) + Zigbee sensors/lighting + Frigate (peephole face recognition) + Grocy kitchen kiosk + Nextcloud calendar sync + Node-RED glue logic + a Sway thin-client media station + a quarter-daily LLM digest.
 
 ---
 
@@ -75,6 +75,16 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato
 ### 1.10 Deferred / out of scope for now
 - **Projector + camera tabletop AI surface** — genuinely interesting but high-effort (projector-camera calibration, fingertip touch detection is an active CV research problem, poor legibility with ambient light). If pursued, treat as an isolated experimental side project, not wired into critical automations. Not included in this plan's budget or phases.
 
+### 1.11 Sway thin-client hardware
+| Item | Est. Price (EUR) | Notes |
+|---|---|---|
+| Mini PC / SFF (Intel N100/N305-class, Quick Sync, wired GbE) | €150–220 | Wired Ethernet strongly preferred over WiFi for Steam Link latency |
+| Display (HDMI monitor/TV) | €0–150 | May already have one |
+| Keyboard + mouse or remote | €20–40 | Local fallback input — primary control is HA/MQTT + wayvnc, not this |
+| USB mic + speaker (mic-enabled rooms only) | €25–50/room | Only for the specific rooms chosen for voice interactivity — see §3 Phase 11 open decisions |
+
+*(No new hardware for Phase 12 — `digest-engine`/`digest-web` run as containers on the existing container-host from Phase 1.)*
+
 ---
 
 ## 2. Software (all open source / self-hosted)
@@ -104,6 +114,27 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato
 | Long-term stats (optional) | **InfluxDB + Grafana** | Only if you want history beyond HA's default recorder retention |
 | Backup | **restic** | Scheduled encrypted backups of all stateful volumes |
 | Kiosk browser | **Chromium (kiosk mode)** | Displays Grocy PWA on the kitchen touchscreen |
+| Thin-client OS build | **live-build** | Builds the thin-client ISO from `hosts/thin-client/live-build/` |
+| Thin-client compositor | **Sway** | Kiosk Wayland compositor |
+| Thin-client autologin | **greetd** | Autologin straight into `sway`, no separate greeter UI |
+| Thin-client remote view/control | **wayvnc** | VNC for wlroots compositors — the chosen remote-control channel (Sway/wlroots has no maintained RDP path; wayvnc replaces RDP for this project) |
+| Thin-client scripted control | **thinclient-agent** (custom) | HA MQTT-discovery entity + `swaymsg`/app-process control; the only surface the LLM can reach, always mediated through HA |
+| Thin-client media | **mpv** + **mpv-mpris** | Local playback with MPRIS2 D-Bus control, bridged into `thinclient-agent`'s HA `media_player` entity |
+| Thin-client music | **spotifyd** / **librespot** | Headless Spotify Connect receiver (Premium required, unofficial protocol) |
+| Thin-client game streaming | **Steam Link** (Flatpak/Flathub) + **Xwayland** | Remote Steam play; Xwayland avoids native-Wayland black-screen/flicker bugs on wlroots |
+| Thin-client browser | **Firefox (kiosk)** | General browsing + the rendering surface for the LLM-generated digest canvas |
+| Thin-client voice | **wyoming-satellite** + **openWakeWord** | Local wake-word spotting, streams to the existing Phase 3 Wyoming faster-whisper/Piper Assist pipeline — no new STT/TTS infrastructure |
+| Digest scheduling | **systemd timer** | 4x/day cadence, same `OnCalendar` pattern as the existing restic backup timer |
+| Digest engine | **digest-engine** (custom Python) | Ingests mail/messages/news/financial data, calls the Phase 3 Ollama host, renders the personal/political/household digest sections |
+| Digest static serving | **digest-web** (nginx:alpine/Caddy) | Serves the rendered digest artifact read-only to both the thin client and an HA iframe card |
+| Digest ingestion — Signal | **signal-cli** | Linked-device (JSON-RPC) read access to Signal messages |
+| Digest ingestion — Telegram | **Telethon** | MTProto client logged in as the real account — the Bot API can't read personal DMs |
+| Digest ingestion — Discord | **discord.py** | Bot with Message Content intent, scoped to servers you own/admin only — no personal-DM access |
+| Digest ingestion — WhatsApp | **whatsapp-bridge** (custom, Node.js + `whatsapp-web.js`/Puppeteer + Xvfb, opt-in) | Real WhatsApp Web session in a virtual display, headful Chromium to avoid headless-detection; still opt-in via `ENABLE_WHATSAPP_INGEST`, off by default |
+| Digest ingestion — mail | **imapclient** | IMAP fetch (App Password or OAuth2/XOAUTH2) |
+| Digest ingestion — news | **feedparser** | Curated OPML feed list, including `https://www.marxist.com/feed/rss` |
+| Digest ingestion — financial | **FRED API** + **Stooq** | Macro/unemployment indicators + stock/oil/commodity data |
+| Digest rendering | **digest-canvas SDK** (custom, vendored) | Offline globe (`addMarker(lat, lon, {icon, color, glow})`), window/panel chrome, and glow/holo CSS primitives the LLM composes against each run |
 
 ---
 
@@ -170,9 +201,77 @@ Already covered — using your existing Haozee CC2652P USB dongle. No coordinato
 3. Additional RuView/Bermuda nodes as more rooms are covered.
 4. Dedicated Frigate accelerator (Hailo-8L) only if you expand beyond 1–2 cameras.
 
+### Phase 11 — Sway thin-client ISO
+1. Scaffold a `live-build` tree at `hosts/thin-client/live-build/` (Debian 12, matching container-host's OS). `config/package-lists/thin-client.list.chroot` pulls `sway`, `greetd`, `wayvnc`, `xwayland`, `firefox-esr`, `mpv`, `mpv-mpris`, `spotifyd` (or `librespot`), `flatpak` (Steam Link), `wyoming-satellite` + `openwakeword` deps, plus `pipewire`/`wireplumber`. `hosts/thin-client/scripts/build-thin-client-iso.sh` drives `lb config && lb build`.
+2. Autologin straight into a kiosk Sway session via **greetd** (`initial_session` block runs `sway` directly, no greeter UI) — not the older getty+`.bash_profile` hack.
+3. Remote control: **wayvnc** for interactive screen view/control. **Sway/wlroots has no maintained RDP path** (wlroots dropped its RDP backend; xrdp is X11-only) — wayvnc is the deliberate, confirmed replacement for "RDP" in this project, not a stopgap.
+4. Build `thinclient-agent` (Python, `hosts/thin-client/agent/`) as a systemd service baked into the image:
+   - Connects to Mosquitto, does HA MQTT-discovery: a `media_player` entity (driven by mpv's MPRIS2 D-Bus state via `mpv-mpris`, bridged in-process), plus `button`/`select` entities for launching apps, switching Sway workspaces, and opening/expanding the digest canvas.
+   - On MQTT command, shells out to `swaymsg` (`$SWAYSOCK`) and manages app processes (Firefox, Steam Link, mpv).
+   - **Security principle**: the LLM never gets a raw network path to the thin client. Every control path is LLM tool call → HA service call → MQTT → `thinclient-agent`, mirroring the Phase 6/8 "HA mediates, nothing auto-acts" precedent.
+5. Dedicate one Sway workspace to a kiosk Firefox window pointed at `digest-engine`'s local HTTP endpoint (Phase 12) — this doubles as the LLM's rendering surface for "free windows/graphics."
+6. Spotify via `spotifyd`/`librespot` (Connect receiver, no GUI login, Premium required, unofficial protocol — minor ongoing-maintenance risk, not a blocker).
+7. Steam Link via Flathub Flatpak, run under **Xwayland** (documented workaround for native-Wayland black-screen/flicker bugs on wlroots).
+8. **Voice interactivity**:
+   - Only the rooms with a chosen thin client *and* an attached mic run `wyoming-satellite` (openWakeWord), streaming to the existing Phase 3 Wyoming faster-whisper/Piper pipeline — no new STT/TTS infrastructure.
+   - `thinclient-agent` accepts an Assist-resolved "play my digest" intent: switches the dedicated Firefox workspace into the "full/thorough" canvas view (vs. the HA dashboard's "compact" view) and narrates via the existing Piper TTS output.
+   - **Room/person routing**: reuse Phase 2's presence system (`person.*`/area entities). If exactly one recognized person is in the room where the wake word fired, play that person's personal digest section. If more than one is present, Assist asks **"whose digest?"** and disambiguates by spoken name before playing the personal section — never guesses. Political/household sections always play regardless of presence ambiguity.
+9. Network placement: plain trusted LAN for now (no VLAN precedent exists for a general client device class yet — only the unbuilt camera-VLAN concept). Revisitable later as a Phase-10-style expansion item, not a blocker now.
+10. **Validate the image boots to a working kiosk session (Sway, local mpv/Spotify playback) with Mosquitto/HA/container-host powered off** — must not hang waiting on the network, same "reactive path never depends on a remote service" philosophy applied to the thin client's own boot path.
+
+### Phase 12 — Quarter-daily LLM digest
+1. Add `digest-engine` to `hosts/container-host`'s compose stack via the existing `ENABLE_X`/`X_BLOCK` pattern (`ENABLE_DIGEST_ENGINE="false"`, off by default until credentials are provisioned). Its build context points at the new top-level `digest-engine/` directory — **the first locally-built image in the stack** (everything else pulls prebuilt registry images).
+2. Add a companion `digest-web` static-file service (nginx:alpine/Caddy) in the same block, serving the shared output volume read-only to **both** display surfaces: the thin client's Firefox workspace (compact-vs-full toggle) and an HA Lovelace HTML/iframe card.
+3. Schedule via a **systemd timer**, mirroring the existing restic-backup convention: `smart-home-digest.service` (oneshot, `docker compose run --rm digest-engine`) + `smart-home-digest.timer` (`OnCalendar=*-*-* 00,06,12,18:00:00`, adjust once Ollama contention is settled — see open decisions).
+4. Ingestion modules, each independently toggleable, each reading credentials from a not-committed `.env`:
+   - **Email** — IMAP via `imapclient`; Gmail needs an App Password (2FA-gated) or OAuth2/XOAUTH2 — App Password recommended for this personal-use case.
+   - **Signal** — `signal-cli` linked as a secondary device (JSON-RPC daemon mode); lowest risk of the four message platforms.
+   - **Telegram** — Telethon (MTProto, logs in as the real account) since the Bot API can't read personal DMs; this is a userbot, ToS-grey but lower enforcement risk than WhatsApp/Discord-selfbot.
+   - **Discord** — `discord.py` bot with Message Content intent, scoped to **servers you own/admin only**; cannot read personal DMs or others' servers without a selfbot (not built — real ban risk, explicit ToS violation).
+   - **WhatsApp** — no officially-sanctioned API option exists. Rather than a protocol-reimplementation library (Baileys), run a small **`whatsapp-bridge`** sidecar (Node.js, `digest-engine/whatsapp-bridge/`): a real Chromium logged into the actual web.whatsapp.com client via **whatsapp-web.js** (Puppeteer), inside its own container running **Xvfb** so Chromium executes **headful** (not `headless: true`) — WhatsApp's automation detection specifically fingerprints headless Chrome, so a virtual-display "real browser" session is meaningfully lower-risk than either Baileys or true-headless whatsapp-web.js, though not zero-risk (it's still automated use of a personal account). One-time interactive QR-code login persists a session directory (mounted volume) so subsequent runs don't need re-scanning. The bridge exposes incoming messages over a local-only channel (e.g. a Unix socket or a small internal HTTP endpoint on the compose network, never published to the LAN) that `digest-engine/ingest/whatsapp_ingest.py` reads each run. Still gate behind `ENABLE_WHATSAPP_INGEST="false"`, off by default, with a warning in script output + `digest-engine/README.md`; recommend a secondary/non-critical number if enabled. Build this one last.
+   - **News** — `feedparser` over a curated OPML list (`digest-engine/feeds/curated-feeds.opml`), seeded with the confirmed `https://www.marxist.com/feed/rss` plus a mainstream-outlet list (exact outlets: see open decisions).
+   - **Financial** — FRED API (macro/unemployment, e.g. `UNRATE`) + Stooq keyless CSV (stocks/oil/commodities, preferred over Alpha Vantage's tight free-tier cap).
+5. LLM synthesis: assemble the run's ingested content into context, call the existing Phase 3 Ollama host with three separate prompt templates (`digest-engine/synth/prompts/{personal,political,household}.md`):
+   - **Personal** — from personal-flagged mail/messages.
+   - **Political** — Marxist/working-class analytical framing (marxist.com feed as theoretical basis) synthesizing mainstream news + financial indicators + politically-flagged mail, laid out on the "holo globe" with colored/glowing markers (e.g. revolutionary-situation markers in red with a hammer-and-sickle/star motif).
+   - **Household/calendar** — from the existing Nextcloud CalDAV integration (Phase 8) and Grocy state (Phase 7).
+   - A **detail-level** parameter (`compact` for the HA iframe, `full` for the thin-client fullscreen view) makes the thin-client rendering genuinely more thorough without needing two independent generation passes.
+6. Rendering: vendor the offline **digest-canvas SDK** under `digest-engine/render/digest-canvas-sdk/` (globe + `addMarker()`, window/panel chrome, glow/holo CSS utility, no CDN dependency). Each run's LLM job is to call into this SDK with structured content, not hand-roll projection math. Use a custom inline SVG or Unicode ☭ (U+262D, explicit font-fallback + CSS glow) for hammer-and-sickle iconography since Nerd Fonts has no such glyph.
+7. **Live follow-up voice Q&A**: persist each run's actually-used ingested-context bundle (not full raw content) as `digest-engine/output//context.json`. Expose a small HA tool (`digest_followup_query`) so a spoken follow-up ("tell me more about the unemployment numbers") feeds the cached context + question back into Ollama for a grounded, low-latency answer — no fresh ingestion pass. The answer can push a new small window/card onto the already-open thin-client canvas via a websocket, keeping the "flexible windows" idea alive live, not just at generation time.
+8. **Security/scope principle**: `digest-engine` is the first component in this project with routine WAN egress (mail, message platforms, news, financial APIs). Run it as its own compose service, no inbound port exposure beyond `digest-web`'s read-only LAN serving; credentials in a git-ignored `.env`. **Everything here is read-only summarization — it must never perform a write action anywhere** (no auto-reply, no mail archive/delete beyond what IMAP fetch requires, no CalDAV/Grocy writes, no message-platform writes), extending the Phase 6/8 "no silent mutation" precedent to its logical extreme: no mutation path exists at all.
+
 ### Testing checklist before calling any phase "done"
 - Does the reactive path (presence → light on) work with the LLM host powered off? (It must.)
 - Does a bad/slow LLM response ever block a light switch? (It must not.)
 - Are cameras verified to have zero WAN egress?
 - Does the calendar integration survive a Nextcloud restart without orphaning entities?
 - Does an identity merge ever get auto-committed without confirmation? (It must not.)
+- Does the thin client boot to a usable kiosk session with Mosquitto/HA/container-host powered off? (It must not hang.)
+- Can the LLM reach the thin client through any path other than HA service call → MQTT → `thinclient-agent`? (It must not.)
+- Does `digest-engine` ever perform a write action anywhere? (It must not — read-only only.)
+- Is WhatsApp ingestion off by default, requiring the explicit opt-in + warning? (It must be.)
+- Does voice digest playback ever read the wrong person's personal section when multiple people are present? (It must not — must ask, never guess.)
+- Does a follow-up voice question ever trigger a write, or only grounded read/synthesis against cached context? (It must stay read-only.)
+- If a run's LLM output produces malformed canvas-SDK calls, does the digest fall back to plain text instead of a broken/blank page?
+- Are all ingestion platform credentials kept out of git (`.env`, gitignored), matching the restic-password handling convention?
+- Does a stale/unreachable "was the digest viewed" signal ever cause runs to merge forever, instead of degrading to "assume viewed" after one missed check? (It must degrade, not compound.)
+- Does the compact HA-dashboard iframe view ever mark a digest as viewed? (It must not — only an actual thin-client canvas display or voice playback counts.)
+
+---
+
+## 4. Open decisions (Phases 11–12)
+
+These need a decision before their respective implementation steps can be built — everything above is written to accommodate any answer, but nothing should be built against an unresolved item.
+
+1. ~~RDP vs. VNC vs. desktop-environment swap~~ — **resolved**: wayvnc (VNC) replaces RDP for this project; Sway is kept.
+2. ~~WhatsApp ingestion approach~~ — **resolved**: `whatsapp-bridge` (headful Chromium + `whatsapp-web.js` in an Xvfb virtual display), not Baileys. Still opt-in (`ENABLE_WHATSAPP_INGEST`); still recommend a secondary/non-critical number, since automating a personal account carries some risk even via the real web client.
+3. **Mainstream news source list** — `digest-engine/feeds/curated-feeds.opml` ships with `marxist.com/feed/rss` (real) plus BBC World/Al Jazeera/Guardian World/DW as clearly-marked placeholders (Reuters/AP were skipped — both have restricted their public RSS and guessing a live URL seemed worse than an honest placeholder). Still needs the user's actual sign-off/edit.
+4. **Ollama contention** — not yet resolved; `DIGEST_SCHEDULE` defaults to `00,06,12,18` (every 6h) in `setup-container-host.sh`, unadjusted for Assist-traffic overlap. Revisit once real usage patterns are known.
+5. **Credential storage** — implemented as a git-ignored `.env` seeded from `digest-engine.env.example` (matches the restic-password precedent); `age`/`sops` was not built, considered adequate for now.
+6. **Exact mic-enabled room list** — still needed; `ENABLE_VOICE_SATELLITE` in `build-thin-client-iso.sh` defaults to `false` per-image until rooms are chosen.
+7. **Exact thin-client hardware target** — still needed; nothing in the built image assumes specific hardware, but Steam Link/Xwayland decode performance can't be validated without it.
+8. **Personal-digest visibility on shared displays** — not yet resolved; not blocking, since the thin-client rendering built so far doesn't yet distinguish "shared screen" from "private."
+9. **Household/calendar ingestion has no real data source yet** (new, found during Phase 12 implementation) — the plan named CalDAV (Phase 8) and Grocy (Phase 7) as the source but no ingest module was written for either; `digest-engine/run.py` currently passes empty calendar/Grocy context and the household prompt is told to say "nothing scheduled" rather than hallucinate. Needs either a new ingest module or a decision to pull this from HA directly.
+10. **HA has no core MQTT `media_player` platform** (new, found during Phase 11 implementation) — `thinclient-agent` publishes the `media_player` discovery payload as specified, but stock Home Assistant ignores it without the HACS "MQTT Media Player" custom integration installed. `button`/`sensor`/`number` entities are also published as a fallback that works on a plain HA install; decide whether to install the HACS integration or keep relying on the fallback entities.
+11. **Several package-availability items still need verification on real hardware** before first boot, all flagged in-code rather than guessed: the Steam Link Flatpak app ID (`com.valvesoftware.SteamLink`), spotifyd/librespot packaging on Debian bookworm (not in main — three fallback install routes documented, none wired to a hardcoded download URL), and `mpv-mpris` packaging.
+12. **wayvnc ships with no password** — `start-wayvnc` fails closed on a sentinel value (`CHANGEME-SET-ON-FIRST-BOOT`) rather than serving unauthenticated VNC; a real password must be generated on the booted machine before wayvnc will start (see `hosts/thin-client/README.md`).
diff --git a/hosts/container-host/scripts/setup-container-host.sh b/hosts/container-host/scripts/setup-container-host.sh
index 9c35fd6..21fad12 100755
--- a/hosts/container-host/scripts/setup-container-host.sh
+++ b/hosts/container-host/scripts/setup-container-host.sh
@@ -17,7 +17,14 @@
 #   - Homepage (single dashboard landing page)
 #   - ntfy (self-hosted push notifications)
 #   - Portainer (Docker GUI)
+#   - gallery-smb (optional, off by default — read-only SMB share of photos for the
+#     Phase 11 thin clients' idle slideshow)
 #   - restic scheduled backups (optional, off by default)
+#   - digest-engine + digest-web (Phase 12 quarter-daily LLM digest, optional,
+#     off by default — needs digest-engine/ from this repo checked out on this
+#     host, see DIGEST_ENGINE_SRC below and digest-engine/README.md)
+#   - whatsapp-bridge (optional, off by default, gated separately — real ban
+#     risk, see digest-engine/README.md before enabling)
 #
 # Run as: sudo ./setup-container-host.sh
 #
@@ -40,6 +47,16 @@ ENABLE_HOMEPAGE="true"
 ENABLE_NTFY="true"
 ENABLE_PORTAINER="true"
 
+# --- Gallery SMB share — off by default until a password is chosen ----------
+# Serves $BASE_DIR/gallery read-only over SMB so idle thin clients cycle through photos
+# instead of blanking (Phase 11). Unlike the restic password below, this one is NOT
+# auto-generated: the identical value has to be typed into
+# /etc/thinclient-agent/gallery-credentials on every thin client, so a secret only this
+# script ever saw would be a secret the other end cannot have. Pick one yourself.
+ENABLE_GALLERY_SMB="false"
+GALLERY_SMB_USERNAME="gallery"
+GALLERY_SMB_PASSWORD=""             # <-- SET THIS before flipping the toggle above
+
 # --- Scheduled backups (restic) — off by default until you pick a target ---
 # Set ENABLE_BACKUPS=true and RESTIC_REPOSITORY to a local path (e.g. an
 # external/USB drive mount, or a NAS mount), or a remote target restic
@@ -54,6 +71,18 @@ BACKUP_SCHEDULE="03:30"             # systemd OnCalendar time, daily at this loc
 # Example: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
 ZIGBEE_USB_DEVICE="/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0"
 
+# --- Quarter-daily LLM digest (Phase 12) — off by default until credentials
+# --- are provisioned. See digest-engine/README.md.
+ENABLE_DIGEST_ENGINE="false"
+# WhatsApp ingestion is separately gated and highest-risk of the four message
+# platforms. Read digest-engine/README.md before setting this to "true" — real
+# ban risk even with the headful-Chromium mitigation; use a secondary number.
+ENABLE_WHATSAPP_INGEST="false"
+# Where this repo's digest-engine/ directory lives on THIS host (build context).
+DIGEST_ENGINE_SRC="/opt/smart-home/src/digest-engine"
+DIGEST_WEB_PORT="8091"             # LAN-facing read-only static serving
+DIGEST_SCHEDULE="00,06,12,18"      # systemd OnCalendar hours, 4x/day
+
 # ---------------------------------------------------------------------------
 # Sanity checks
 # ---------------------------------------------------------------------------
@@ -78,6 +107,27 @@ if [[ "$ENABLE_BACKUPS" == "true" && "$RESTIC_REPOSITORY" == "/mnt/backup/smart-
   echo "  Edit RESTIC_REPOSITORY at the top of this script to point at real backup storage."
 fi
 
+if [[ "$ENABLE_DIGEST_ENGINE" == "true" && ! -d "$DIGEST_ENGINE_SRC" ]]; then
+  echo "Warning: ENABLE_DIGEST_ENGINE=true but $DIGEST_ENGINE_SRC does not exist."
+  echo "  Copy or clone this repo's digest-engine/ directory there — it is the"
+  echo "  build context for the digest-engine image — then re-run."
+fi
+
+if [[ "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then
+  echo "WARNING: WhatsApp ingestion is enabled."
+  echo "  There is no officially sanctioned way to read WhatsApp programmatically."
+  echo "  whatsapp-bridge runs a real headful Chromium logged in as a linked device,"
+  echo "  which lowers but does not remove the ban risk (~2-8 weeks, historically)."
+  echo "  Use a secondary/non-critical number. See digest-engine/README.md."
+fi
+
+if [[ "$ENABLE_GALLERY_SMB" == "true" && -z "$GALLERY_SMB_PASSWORD" ]]; then
+  echo "Warning: ENABLE_GALLERY_SMB=true but GALLERY_SMB_PASSWORD is still empty."
+  echo "  Set a real password at the top of this script — the same value then has to be"
+  echo "  typed into /etc/thinclient-agent/gallery-credentials on every thin client that"
+  echo "  should mount this share, since nothing here can push it to those machines."
+fi
+
 HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
 HOST_IP="${HOST_IP:-}"
 
@@ -156,6 +206,25 @@ fi
 if [[ "$ENABLE_PORTAINER" == "true" ]]; then
   mkdir -p "$BASE_DIR"/portainer/data
 fi
+if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then
+  # The share root itself. Nothing seeds it with photos — that's a human copying
+  # files in; an empty share is a valid, harmless state (the thin-client slideshow
+  # skips idle-gallery mode with nothing to show, same as an unreachable share).
+  mkdir -p "$BASE_DIR"/gallery
+fi
+if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then
+  mkdir -p "$BASE_DIR"/digest/{output,data}
+  if [[ "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then
+    mkdir -p "$BASE_DIR"/digest/data/whatsapp-bridge
+  fi
+  # The real credentials file. Seed it from the committed template on first run;
+  # 600 like backup.env, and never committed (repo .gitignore covers *.env).
+  if [[ ! -f "$BASE_DIR/digest/digest-engine.env" ]]; then
+    cp "$DIGEST_ENGINE_SRC/digest-engine.env.example" "$BASE_DIR/digest/digest-engine.env"
+    chmod 600 "$BASE_DIR/digest/digest-engine.env"
+    echo "  Seeded $BASE_DIR/digest/digest-engine.env from the template — fill in real values."
+  fi
+fi
 
 # ---------------------------------------------------------------------------
 # 4. Mosquitto config
@@ -414,6 +483,99 @@ if [[ "$ENABLE_PORTAINER" == "true" ]]; then
 "
 fi
 
+GALLERY_SMB_BLOCK=""
+if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then
+  # servercontainers/samba over dperson/samba: dperson's image has gone without
+  # maintainer updates long enough that forks exist specifically to patch CVEs in
+  # it; servercontainers is the actively maintained one as of this writing.
+  # ACCOUNT_gallery / SAMBA_VOLUME_CONFIG_gallery is that image's env-driven
+  # config surface — no config file to template, just these two variables.
+  GALLERY_SMB_BLOCK="
+  gallery-smb:
+    image: ghcr.io/servercontainers/samba:latest
+    container_name: gallery-smb
+    restart: unless-stopped
+    ports:
+      - \"445:445\"
+    environment:
+      - TZ=${TIMEZONE}
+      - ACCOUNT_${GALLERY_SMB_USERNAME}=${GALLERY_SMB_PASSWORD}
+      - SAMBA_VOLUME_CONFIG_gallery=path = /shares/gallery; valid users = ${GALLERY_SMB_USERNAME}; guest ok = no; read only = yes; browseable = yes
+    volumes:
+      - ${BASE_DIR}/gallery:/shares/gallery
+    cap_add:
+      - NET_ADMIN
+"
+fi
+
+# digest-engine and whatsapp-bridge are the FIRST locally-built images in this
+# stack — everything else above pulls a prebuilt registry image. That means
+# this host needs the digest-engine/ source tree present at DIGEST_ENGINE_SRC,
+# and a `docker compose build` after any code change there.
+DIGEST_ENGINE_BLOCK=""
+DIGEST_WEB_BLOCK=""
+WHATSAPP_BRIDGE_BLOCK=""
+if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then
+  # profiles: keeps this out of `docker compose up -d` — it's a oneshot driven
+  # by smart-home-digest.timer below. `docker compose run --rm digest-engine`
+  # still works regardless of profile.
+  DIGEST_ENGINE_BLOCK="
+  digest-engine:
+    build: ${DIGEST_ENGINE_SRC}
+    image: smart-home/digest-engine:local
+    container_name: digest-engine
+    profiles:
+      - oneshot
+    restart: \"no\"
+    env_file:
+      - ${BASE_DIR}/digest/digest-engine.env
+    volumes:
+      - ${BASE_DIR}/digest/output:/output
+      - ${BASE_DIR}/digest/data:/data
+      - /etc/localtime:/etc/localtime:ro
+    environment:
+      - TZ=${TIMEZONE}
+"
+
+  # Static, read-only serving of the rendered digest to the thin client's kiosk
+  # Firefox workspace and an HA Lovelace iframe card.
+  DIGEST_WEB_BLOCK="
+  digest-web:
+    image: nginx:alpine
+    container_name: digest-web
+    restart: unless-stopped
+    ports:
+      - \"${DIGEST_WEB_PORT}:80\"
+    volumes:
+      - ${DIGEST_ENGINE_SRC}/render/templates/compact.html:/usr/share/nginx/html/compact.html:ro
+      - ${DIGEST_ENGINE_SRC}/render/templates/full.html:/usr/share/nginx/html/full.html:ro
+      - ${DIGEST_ENGINE_SRC}/render/digest-canvas-sdk:/usr/share/nginx/html/digest-canvas-sdk:ro
+      - ${BASE_DIR}/digest/output:/usr/share/nginx/html/output:ro
+    environment:
+      - TZ=${TIMEZONE}
+"
+fi
+
+if [[ "$ENABLE_DIGEST_ENGINE" == "true" && "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then
+  # Long-lived, unlike digest-engine: holds the logged-in WhatsApp Web session
+  # open and appends to /data/messages.jsonl, which digest-engine drains each
+  # run. No ports — never reachable from the LAN, only via the shared volume.
+  # shm_size is required: Chromium's renderer crashes on Docker's default 64MB.
+  WHATSAPP_BRIDGE_BLOCK="
+  whatsapp-bridge:
+    build: ${DIGEST_ENGINE_SRC}/whatsapp-bridge
+    image: smart-home/whatsapp-bridge:local
+    container_name: whatsapp-bridge
+    restart: unless-stopped
+    shm_size: \"512mb\"
+    volumes:
+      - ${BASE_DIR}/digest/data/whatsapp-bridge:/data
+      - /etc/localtime:/etc/localtime:ro
+    environment:
+      - TZ=${TIMEZONE}
+"
+fi
+
 cat > "$BASE_DIR/docker-compose.yml" < /etc/systemd/system/smart-home-digest.service < /etc/systemd/system/smart-home-digest.timer <}"
+  echo "     you set at the top of this script into /etc/thinclient-agent/gallery-credentials"
+  echo "     — see hosts/thin-client/README.md."
+fi
 echo
 echo "Updating later: cd $BASE_DIR && docker compose pull && docker compose up -d"
 echo "Backing up manually: sudo $BASE_DIR/backup.sh (requires ENABLE_BACKUPS=true was run once)"
diff --git a/hosts/thin-client/README.md b/hosts/thin-client/README.md
new file mode 100644
index 0000000..cb2d229
--- /dev/null
+++ b/hosts/thin-client/README.md
@@ -0,0 +1,369 @@
+# Sway thin client
+
+Phase 11 of `docs/project-plan.md`. Builds a Debian 12 live ISO for a kiosk media
+station: autologin into Sway, remote-controlled by Home Assistant over MQTT and by a
+human over wayvnc.
+
+What ends up on the image:
+
+| | |
+|---|---|
+| Compositor | Sway, no bars, no lock screen, workspaces `1:web` / `2:digest` / `3:media` |
+| Autologin | greetd, `default_session` straight into `/usr/local/bin/kiosk-session` |
+| Remote control (human) | wayvnc on `0.0.0.0:5900`, authentication required |
+| Remote control (HA/LLM) | `thinclient-agent`, a systemd service publishing HA MQTT-discovery entities |
+| Browser | Firefox ESR in kiosk mode on the digest workspace, pointed at `digest-web` |
+| Media | mpv + mpv-mpris, playerctl, PipeWire/WirePlumber |
+| Game streaming | Steam Link (Flathub flatpak) forced onto Xwayland |
+| Voice | wyoming-satellite + openWakeWord — **opt-in, off by default** |
+| Gesture control | Camera hand tracking (MediaPipe) — **opt-in, off by default, see below** |
+
+## Before you build
+
+> **Two open decisions from `docs/project-plan.md` §4 are still unresolved and block a
+> real build + flash:**
+>
+> - **#7 — no thin-client hardware has been chosen.** Nothing here has been booted on
+>   real metal. The GPU, audio device, and Steam Link/Xwayland decode performance are
+>   all unvalidated, and `wyoming-satellite`'s `--mic-command`/`--snd-command` are still
+>   set to the ALSA `default` device because the microphone is unknown.
+> - **#6 — no mic-enabled room list has been chosen.** `ENABLE_VOICE_SATELLITE` is a
+>   **per-image** setting, not a global one: build one ISO with it `false` for the silent
+>   rooms and a separate ISO with it `true` for each room that actually has a mic. Do
+>   not turn it on until that list exists.
+
+Then edit the `# CONFIGURATION` block at the top of
+[`scripts/build-thin-client-iso.sh`](scripts/build-thin-client-iso.sh):
+
+| Variable | What to put in it |
+|---|---|
+| `MQTT_BROKER_HOST` | LAN IP of the container host running Mosquitto (Phase 1) |
+| `HA_URL` | Home Assistant URL, e.g. `http://192.168.1.10:8123` |
+| `DIGEST_WEB_URL` | The Phase 12 `digest-web` service. Placeholder until that is deployed — the image builds and boots fine without it, the digest workspace just shows a connection error. |
+| `KIOSK_USERNAME` | Autologin account name (`kiosk`) |
+| `THINCLIENT_NAME` / `IMAGE_HOSTNAME` | Per-room identity; each thin client needs its own |
+| `ENABLE_STEAM_LINK` | `true`/`false` |
+| `ENABLE_VOICE_SATELLITE` | `false` unless this specific image is for a mic-enabled room |
+| `ENABLE_GESTURE_CONTROL` | `false` unless this specific image is for a camera-enabled room. Installs the software only — the camera still stays off. See the privacy section below. |
+| `SSH_AUTHORIZED_KEY` | Optional. Password auth is disabled on the image, so without a key the only admin paths are the local console and wayvnc. |
+
+## Build
+
+```sh
+sudo ./scripts/build-thin-client-iso.sh
+```
+
+It installs `live-build` if missing, regenerates
+`live-build/config/includes.chroot/` from `configs/` and `agent/`, writes
+`/etc/thinclient-agent/config.env` into the image, then runs `lb config && lb build`.
+The ISO lands in `live-build/`. The script ends with a numbered list of what to verify
+on first boot.
+
+### Directory split
+
+`configs/` and `agent/` are the human-edited, git-tracked source of truth.
+`live-build/config/includes.chroot/` is **generated** — it is wiped and rebuilt on every
+run and is gitignored. Never hand-edit anything under it; edits there are lost on the
+next build.
+
+The only templated token in `configs/` is `@KIOSK_USERNAME@`, substituted by the build
+script. Everything the in-chroot hooks need is read from
+`/etc/thinclient-agent/config.env`, which live-build copies in (`chroot_local-includes`)
+before it runs the hooks (`chroot_local-hooks`) — that ordering is what lets the hooks
+be plain scripts with no build-script variables of their own.
+
+## The wayvnc password is not in this repo — set it on first boot
+
+`configs/wayvnc/config` deliberately has no `password=` line. wayvnc only accepts the
+password inline, so shipping one would put a live credential for a full remote-control
+channel into git. Instead:
+
+- `0300-wayvnc.hook.chroot` writes the sentinel `CHANGEME-SET-ON-FIRST-BOOT` into
+  `/etc/wayvnc/wayvnc-password` (mode 0600).
+- `/usr/local/bin/start-wayvnc` **refuses to launch** while that sentinel is there, so
+  the failure mode is "no remote access" rather than "an unauthenticated VNC server
+  listening on the LAN".
+
+On the booted machine:
+
+```sh
+sudo sh -c 'openssl rand -base64 24 > /etc/wayvnc/wayvnc-password'
+sudo chmod 600 /etc/wayvnc/wayvnc-password
+sudo chown kiosk:kiosk /etc/wayvnc/wayvnc-password
+swaymsg reload
+```
+
+Then connect to `:5900` with username `kiosk`.
+
+## Camera gesture control is a camera in a room — read this before enabling it
+
+> **If you turn this on, a camera continuously captures and analyses video of the room
+> for as long as the kiosk session is up.** Not on a trigger, not on a wake word — every
+> frame, all day. Frames are processed in memory and nothing is recorded or sent
+> anywhere, but that is a property of this code today, not a guarantee the hardware
+> gives you: the camera is physically pointed at the room whatever the software does.
+>
+> **It is off by default and must stay off unless the room has agreed to it.** This is
+> the same decision, made the same way, as the microphones: per `docs/project-plan.md`
+> Phase 11.8 only the specific rooms chosen for voice interactivity get a mic, and
+> `ENABLE_VOICE_SATELLITE` is a per-image build flag precisely so that a silent room's
+> ISO physically cannot listen. **Only specific rooms get a gesture-control camera**, and
+> `ENABLE_GESTURE_CONTROL` is per-image for exactly the same reason. A shared media
+> station in a living room is not a place to switch a camera on opportunistically because
+> the feature happened to be available.
+>
+> There is no HA entity for this and there deliberately never will be — nothing reachable
+> over MQTT can turn the camera on. Enabling it takes physical or SSH access to the
+> machine.
+
+Open hand moves the pointer, closed fist clicks. It is the same idea as the **Pointer
+up/down/left/right** / **Left click** buttons in the HA entity list below — a way to
+drive the kiosk without a VNC session — done from the sofa instead of from a phone.
+
+**Hardware: a USB webcam, per camera-enabled room.** Not part of the base thin-client
+bill of materials; it is a config-gated addition to the rooms that opt in, exactly like
+the USB mic in the mic-enabled rooms. Any ordinary UVC webcam that shows up as a
+`/dev/video*` node works; nothing here needs a depth camera or an accelerator.
+
+### Two separate gates, both defaulting to off
+
+| Gate | Where | What it decides |
+|---|---|---|
+| `ENABLE_GESTURE_CONTROL` | build script, per image | Whether MediaPipe and its ~400 MB dependency tree are installed at all |
+| `"enabled"` | `gesture-config.json`, per machine at runtime | Whether the camera is ever **opened** |
+
+The second one is the one that matters. It is `false` even in an image built with the
+first one on, so flashing a gesture-capable ISO is not the same act as switching a room's
+camera on. `gesture_pointer.py` checks it *before it imports OpenCV or MediaPipe*, so
+while it is false the video stack is never loaded and `/dev/video0` is never opened — not
+opened and ignored.
+
+To turn it on, on the booted machine:
+
+```sh
+v4l2-ctl --list-devices                                    # find the right /dev/videoN
+sudo $EDITOR /var/lib/thinclient-agent/gesture-config.json # "enabled": true
+swaymsg reload
+```
+
+`gesture-config.json` follows the same template/runtime-copy split as `audio-config.json`
+and `rdp-vnc.json`: the committed file is baked in read-only at
+`/etc/thinclient-agent/`, and `runtime_state.ensure_runtime_copy()` seeds the writable
+one under `/var/lib/thinclient-agent/` on first run. Edit the runtime copy — editing the
+`/etc` one does nothing, and it is inside a squashfs anyway.
+
+### Where it runs, and why it is not part of `thinclient-agent`
+
+`configs/gesture-control/gesture_pointer.py` is a **separate process**, started as a sway
+`exec_always` via `/usr/local/bin/gesture-control` — the same shape as the eww
+now-playing widget, not a new module inside the `thinclient-agent` daemon. Three reasons,
+all pointing the same way:
+
+- **Privacy.** A systemd system unit would hold the camera open from boot to shutdown,
+  including while no session exists and there is nothing to point at. Tying the camera's
+  lifetime to the session's means "the screen is up" and "the camera is open" cannot
+  drift apart.
+- **Blast radius.** `thinclient-agent` is the machine's sole MQTT control surface (see
+  the security-boundary note below) and has to stay reliable. A continuous camera read
+  plus ML inference is a completely different failure and CPU profile, and a crash in it
+  must not be able to take HA control of the room down with it. This is the same call
+  `configs/eww/fullscreen-watcher.sh` already makes.
+- **Interpreter.** MediaPipe is PyPI-only and lives in its own venv under
+  `/opt/gesture-control` (bookworm's system Python is PEP 668 externally-managed — the
+  same situation as `wyoming-satellite`). `thinclient-agent` runs on the system
+  interpreter, which cannot import it.
+
+It does *not* reimplement input injection: it imports
+`thinclient_agent.input_control.InputControl` (stdlib-only, so it loads fine from inside
+the venv) and calls `click("LEFT")` and `move_relative()`. `move_relative()` is the one
+thing added to that module — continuous pointer control genuinely needs proportional
+deltas rather than the four fixed 20px directions the HA buttons use, and putting it
+there keeps a single ydotool call path with a single dialect detection for the whole
+image. It is clamped to ±200px per step and is **not** wired to any MQTT topic; the
+inbound control surface is unchanged.
+
+## Idle photo slideshow
+
+After 15 minutes idle, the thin client mounts a read-only SMB share (`gallery-smb` on
+the container host, `ENABLE_GALLERY_SMB` in `setup-container-host.sh`) and cycles
+through whatever images are in it, instead of just blanking the panel. It falls all
+the way back to that original blank-the-panel behaviour — not a broken/blank
+slideshow window — if any of the following is true: `/etc/thinclient-agent/
+gallery-credentials` hasn't been created yet, the share is unreachable, or it's empty.
+A freshly built or offline thin client behaves exactly as it did before this feature.
+
+Nothing here is an HA entity — it's a local `swayidle` timeout/resume pair
+(`configs/sway/config`) calling `/usr/local/bin/idle-gallery`, which mounts the share
+with the kiosk user's already-unrestricted `sudo` (see the wayvnc-password section
+above for the same reasoning about physical-access trust) and hands a shuffled
+playlist to `mpv`.
+
+**Set this up**: create `/etc/thinclient-agent/gallery-credentials` (`chmod 600`) from
+the `.example` next to it, with the same username/password as
+`GALLERY_SMB_USERNAME`/`GALLERY_SMB_PASSWORD` in `setup-container-host.sh` — this
+project has no way to push a secret from one machine to the other, so both sides are
+set by hand from the same value. Also fill in `GALLERY_SMB_HOST` in
+`build-thin-client-iso.sh` (the container host's LAN IP) before rebuilding.
+
+## Home Assistant entities
+
+`thinclient-agent` publishes MQTT-discovery configs on connect. Under the MQTT
+integration you should get one device per thin client with:
+
+- **Show digest canvas** (button) — switches to `2:digest` and reloads Firefox
+- **Digest detail level** (select) — `compact` / `full`
+- **Workspace** (select) — `1:web` / `2:digest` / `3:media`
+- **Launch Firefox**, **Launch web browser**, **Launch Steam Link** (buttons)
+- **Playback state** (sensor, with track metadata as attributes), **Volume** (number),
+  and play/pause / next / previous / stop (buttons)
+- A **media_player** discovery payload — see the caveat below
+- **Audio output** (select) — WirePlumber sinks by human-readable description; the
+  choice is written to `audio-config.json` and survives a reboot
+- **Remote desktop target** (select) plus **Remote desktop connect** / **disconnect**
+  (buttons) — outbound RDP/VNC to another machine via Remmina, configured in
+  `configs/remote-desktop/rdp-vnc.json`. Distinct from wayvnc, which is inbound.
+- **Type text** (text field with a submit action) and **Pointer up/down/left/right**,
+  **Left click**, **Right click** (buttons) — types into, and moves/clicks in,
+  whatever window has focus, via `ydotool`. Meant for the HA mobile app when a full
+  VNC session is overkill (searching in the kiosk Firefox, dismissing a dialog).
+
+A manually-authored Lovelace card grouping the last two groups (audio output, remote
+desktop, and the text/pointer controls) under one "Thin client remote" section reads
+far better in the mobile app than the auto-generated entity list — nothing in this repo
+builds that card, since it is a few lines of YAML per household and not something a
+generic image should assume.
+
+A now-playing widget also appears on-screen (not an HA entity — it is local to the
+kiosk display) whenever audio-only media is playing: cover art, track/artist, and
+prev/play-pause/next, via `eww`. It hides completely the moment anything visual is on
+screen — fullscreen video, Steam Link, or an mpv window with a video track — so it
+never draws over a film. See `configs/eww/fullscreen-watcher.sh`.
+
+> **Caveat: core Home Assistant's MQTT integration has no `media_player` platform.**
+> The plan calls for a `media_player` entity and the agent publishes that payload, but
+> stock HA ignores it; it is only picked up with the HACS *MQTT Media Player* custom
+> integration installed. The sensor/number/button entities above are published
+> alongside it precisely so transport control works on a plain HA install without that
+> add-on. Verify which you want before wiring up automations.
+
+### Security boundary
+
+`thinclient_agent/mqtt_discovery.py` is the **entire** inbound control surface of this
+machine. Per Phase 11.4 the LLM never gets a direct network path here — the only chain
+is *LLM tool call → HA service call → MQTT → thinclient-agent*. There is no HTTP
+listener, no websocket server, no exposed Sway IPC socket. New control features belong
+as additional MQTT entities, not as a second listener.
+
+Related: `thinclient_agent/digest_canvas.py` does **no** presence, person, or room
+resolution. Home Assistant resolves *who* and *where* (including the "whose digest?"
+disambiguation when several people are in the room) and sends an already-resolved
+request; this agent only shows what it is told to show.
+
+## Manual verification still outstanding
+
+None of this has been run on hardware. In rough order:
+
+1. The ISO builds at all (`lb build` is network-heavy and can fail on mirror hiccups).
+2. greetd lands in Sway with no login prompt, on vt1, with getty@tty1 masked.
+3. wayvnc refuses to start with the sentinel password, and works once one is set.
+4. `thinclient-agent` connects to Mosquitto and the device appears in HA.
+5. mpv playback drives the Playback state sensor via mpv-mpris → playerctl.
+6. Steam Link launches under Xwayland without the wlroots black-screen bug —
+   **and that the flatpak app ID `com.valvesoftware.SteamLink` is correct**; it is
+   flagged for verification against the live Flathub listing in
+   `0400-flatpak-steamlink.hook.chroot`.
+7. Spotify Connect: neither `spotifyd` nor `librespot` is in bookworm main, so
+   `0500-spotify-connect.hook.chroot` currently stops at a documented placeholder
+   rather than hardcoding a release URL that would rot. Pick an install route and fill
+   it in.
+8. `wyoming-satellite` / `wyoming-openwakeword` install from PyPI —
+   `0600-voice-satellite.hook.chroot` installs them into a venv under `/opt`
+   (bookworm's system Python is PEP 668 externally-managed, and these pull a large
+   onnxruntime/numpy tree that has no business overwriting apt-managed versions). If
+   the PyPI names turn out to be wrong, the hook comments point at upstream's
+   git-clone + `script/setup` install instead.
+9. **Phase 11.10**: power off the container host and confirm the kiosk still boots to a
+   usable session and plays local media. `thinclient-agent` uses `connect_async` and
+   its unit is deliberately not `After=network-online.target`, so it should never be
+   able to stall the session.
+10. `eww` is not in Debian bookworm main — `0800-eww-widget.hook.chroot` tries apt and
+    otherwise leaves the now-playing widget absent (it degrades cleanly; nothing else
+    depends on it). Pick an install route (cargo, or a release binary) if you want it.
+11. **The Firefox extension IDs in `configs/firefox/policies.json` are unverified.**
+    The AMO download-URL slugs (`ublock-origin`, `sponsorblock`) were checked against
+    the live listings, but the `ExtensionSettings` *keys* (each extension's internal
+    ID, e.g. `uBlock0@raymondhill.net`) are widely-published values reproduced from
+    memory, not confirmed against AMO directly. If an extension silently fails to
+    force-install, check `about:policies` and correct the key from
+    `about:debugging#/runtime/this-firefox` on a manually-installed copy.
+12. `remmina-plugin-rdp`/`remmina-plugin-vnc` package names are assumed, not confirmed
+    against bookworm's actual archive — if `apt-get install` for them fails, check
+    `apt-cache search remmina-plugin` on the build host and adjust the package list.
+13. ydotool: whether `ydotoold` exists (and therefore which command-line dialect
+    applies) depends on the exact bookworm package version — `1000-ydotool.hook.chroot`
+    detects this at build time and `input_control.py` detects it again at runtime, but
+    neither has been checked against the real packaged version yet. If the HA text/
+    pointer controls do nothing, start here: `systemctl status ydotoold` and
+    `journalctl -u thinclient-agent`.
+14. Outbound RDP/VNC: fill in real `host`/`port` entries in
+    `configs/remote-desktop/rdp-vnc.json` and create
+    `/etc/thinclient-agent/remote-desktop-credentials.env` on the booted machine from
+    the shipped `.example` (never baked into the image, same handling as the wayvnc
+    password) before the **Remote desktop connect** button will reach anything real.
+15. Fill in a real `preferred_sink` in `configs/audio/audio-config.json` (or leave it
+    empty and pick one later from the **Audio output** select in HA) — the shipped
+    template has no sink configured, which is a supported/safe default, not a gap.
+16. Keyboard layout defaults to German (`KEYBOARD_LAYOUT="de"` in
+    `build-thin-client-iso.sh`) — applies to both the console (`/etc/default/keyboard`)
+    and Sway (`input type:keyboard { xkb_layout ... }`). Build a separate image with a
+    different value for a non-German room; there is no per-room override at runtime.
+    If `ENABLE_INSTALLER="true"`, `live-build/config/preseed.cfg` also preseeds
+    debian-installer's keyboard step to German (as a default, not a skipped question)
+    — unverified against a real installer run, since the normal path never uses it.
+17. **Camera gesture control — nothing about it has been measured on real hardware, and
+    the numbers below are estimates, not results.** It is off by default so an
+    unverified feature cannot surprise anyone; enable it on a test box before a room.
+    - **CPU load.** Google publishes 17.12 ms per frame for the float16 hand-landmarker
+      on a Pixel 6 CPU, and a desktop x86 report puts the full Python pipeline at
+      20–30 ms/frame and ~16 % of CPU. An Intel N100 is four Gracemont E-cores with AVX2
+      and no AVX-512, so expect the *slower* end of that — plan on the inference loop
+      eating a meaningful fraction of one core continuously. That is why
+      `inference_fps` defaults to `12` rather than the camera's 30: pointer control does
+      not need 30 fps and the cost scales almost linearly with it. Measure with
+      `top -p "$(pgrep -f gesture_pointer.py)"` and turn `inference_fps` down until it is
+      acceptable. **Check it specifically while Steam Link is streaming** — that is the
+      one workload on this image that already wants the whole CPU, and if the two cannot
+      coexist, that is an argument for gesture control being a per-room feature rather
+      than a fix in this code.
+    - **Accuracy and false-positive clicks.** The open/fist thresholds
+      (`FIST_MIN_CURLED` / `OPEN_MAX_CURLED` in `gesture_pointer.py`) and the
+      `fist_hold_seconds` / `click_cooldown_seconds` defaults are reasoned guesses, never
+      tested against a real hand at real room distance in real lighting. The failure mode
+      that matters is a spurious click, so if it fires by itself, raise
+      `fist_hold_seconds` first.
+    - **Pointer feel.** `dead_zone` and `pointer_speed` set how twitchy it is. Untuned.
+    - **Camera latency.** The loop reads every frame and discards the ones it does not
+      analyse, specifically so a V4L2 backlog cannot make the pointer lag the hand. Not
+      verified that `CAP_PROP_BUFFERSIZE`/`CAP_PROP_FPS` are honoured by any given
+      webcam — many ignore them.
+    - **MediaPipe API.** Written against the current **Tasks** API
+      (`mediapipe.tasks.python.vision.HandLandmarker`). The old
+      `mediapipe.solutions.hands` is not merely deprecated — the `mediapipe.solutions`
+      package was *removed* from the wheel around 0.10.31 and is gone in 1.0.0, which is
+      also why `1100-gesture-control.hook.chroot` has to download
+      `hand_landmarker.task` separately instead of it coming inside the wheel.
+    - **Install.** `pip install mediapipe` is unrun here. 1.0.0 ships
+      `py3-none-manylinux_2_28_x86_64`; bookworm is glibc 2.36 / Python 3.11, which fits,
+      but confirm rather than assume. If it fails the hook stops at a documented
+      placeholder and the rest of the image is unaffected.
+18. Maintenance shell: `Super+Shift+Ctrl+M` opens a floating `foot` terminal locally.
+    This is a physical-access escape hatch, deliberately outside the MQTT/HA control
+    surface described above — see the comment above the keybind in `configs/sway/config`
+    for why that is correct rather than an inconsistency.
+19. Idle photo slideshow — none of `idle-gallery.sh`'s CIFS mount options have been
+    tried against a real Samba server. If `mount.cifs` rejects `vers=3.0` (an older
+    NAS, say) or the `soft,retry=0` combination doesn't fail as fast as intended on a
+    truly dead host, adjust the `-o` string there. The `ghcr.io/servercontainers/samba`
+    `ACCOUNT_`/`SAMBA_VOLUME_CONFIG_` env-var syntax in
+    `setup-container-host.sh` is confirmed against that project's documentation, but
+    the container itself was never actually run.
diff --git a/hosts/thin-client/agent/requirements.txt b/hosts/thin-client/agent/requirements.txt
new file mode 100644
index 0000000..e3e0e20
--- /dev/null
+++ b/hosts/thin-client/agent/requirements.txt
@@ -0,0 +1,8 @@
+# The ISO installs this from apt (python3-paho-mqtt, 1.6.x on bookworm) rather than
+# pip — see live-build/config/package-lists/thin-client.list.chroot. This file is for
+# running the agent outside the image (development, a venv on a test box). The code
+# works against both the 1.x and 2.x callback APIs.
+paho-mqtt>=1.6
+
+# Config is read from /etc/thinclient-agent/config.env by a small parser in main.py,
+# so there is no python-dotenv dependency.
diff --git a/hosts/thin-client/agent/thinclient-agent.service b/hosts/thin-client/agent/thinclient-agent.service
new file mode 100644
index 0000000..0a31640
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient-agent.service
@@ -0,0 +1,33 @@
+[Unit]
+Description=Thin client agent (Home Assistant MQTT control surface for Sway)
+Documentation=file:///opt/thinclient-agent
+# A system unit rather than a `systemctl --user` unit: the agent has to be reachable
+# over MQTT whether or not a graphical session is up, and it has to survive sway
+# restarting (a user unit bound to graphical-session.target would go down with it).
+# The session-scoped bits it needs — SWAYSOCK, XDG_RUNTIME_DIR,
+# DBUS_SESSION_BUS_ADDRESS — are derived at call time from the kiosk user's runtime
+# directory in sway_control.SwayControl.session_env(), which also means they are
+# re-resolved after every sway restart instead of being frozen at unit start.
+#
+# Deliberately NOT After=network-online.target: project-plan Phase 11.10 requires the
+# thin client to boot to a usable kiosk session with the container host powered off.
+After=network.target
+
+[Service]
+Type=simple
+User=@KIOSK_USERNAME@
+Group=@KIOSK_USERNAME@
+WorkingDirectory=/opt/thinclient-agent
+Environment=PYTHONPATH=/opt/thinclient-agent
+Environment=PYTHONUNBUFFERED=1
+EnvironmentFile=-/etc/thinclient-agent/config.env
+ExecStart=/usr/bin/python3 -m thinclient_agent.main
+Restart=always
+RestartSec=5
+# Sandboxing stops here on purpose: this unit's whole job is to spawn GUI child
+# processes (Firefox, the Steam Link flatpak) that need the real /tmp, the user's
+# home, and the session bus.
+NoNewPrivileges=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/hosts/thin-client/agent/thinclient_agent/__init__.py b/hosts/thin-client/agent/thinclient_agent/__init__.py
new file mode 100644
index 0000000..6bfc0e6
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/__init__.py
@@ -0,0 +1,3 @@
+"""thinclient-agent — Home Assistant MQTT control surface for a Sway thin client."""
+
+__version__ = "0.1.0"
diff --git a/hosts/thin-client/agent/thinclient_agent/audio_control.py b/hosts/thin-client/agent/thinclient_agent/audio_control.py
new file mode 100644
index 0000000..20093e5
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/audio_control.py
@@ -0,0 +1,211 @@
+"""Persistent audio-output selection via WirePlumber's wpctl.
+
+Two identifiers are in play and confusing them is the main failure mode here. `wpctl`
+takes a numeric object ID, which WirePlumber reassigns on every boot and every device
+hotplug — useless for persistence. `node.name` is stable across both, which is what
+audio-config.json stores. Home Assistant is shown neither: the select lists the human
+descriptions ("Built-in Audio Analog Stereo"), because those are what someone picking
+an output in a mobile app can actually recognise.
+
+Per the security note in mqtt_discovery.py, the payload from HA is only ever used to
+look up an entry in the sink table parsed from `wpctl status`. It never reaches a
+command line: what does is the integer ID that lookup returns.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+import subprocess
+from dataclasses import dataclass
+
+from .runtime_state import ensure_runtime_copy, load_json, save_json
+
+log = logging.getLogger(__name__)
+
+CONFIG_FILENAME = "audio-config.json"
+SYSTEM_DEFAULT = "system-default"
+
+# `wpctl status` prints a tree; sink rows look like
+#   │  *   47. Built-in Audio Analog Stereo    [vol: 0.65]
+# with a leading "*" on the current default. The box-drawing prefix varies between
+# WirePlumber versions, so the row is matched from the ID onwards rather than anchored.
+_SINK_ROW = re.compile(r"(\*?)\s*(\d+)\.\s+(.*?)(?:\s+\[vol:.*)?$")
+_SECTION = re.compile(r"^\s*[^\w]*\s*(\w[\w /]*):\s*$")
+
+
+@dataclass(frozen=True)
+class Sink:
+    node_id: int
+    description: str
+    node_name: str
+    is_default: bool
+
+
+class AudioControl:
+    def __init__(self, env_provider):
+        # wpctl talks to the user's PipeWire session, so it needs the same XDG_RUNTIME_DIR
+        # derivation SwayControl uses for swaymsg and playerctl.
+        self._env_provider = env_provider
+        self.config_path = ensure_runtime_copy(CONFIG_FILENAME)
+        config = load_json(self.config_path)
+        self.preferred_sink = str(config.get("preferred_sink") or "")
+        self.fallback = str(config.get("fallback") or SYSTEM_DEFAULT)
+        self._sinks: list[Sink] = []
+
+    # --- wpctl --------------------------------------------------------------
+    def _wpctl(self, *args: str) -> str | None:
+        try:
+            result = subprocess.run(
+                ["wpctl", *args],
+                env=self._env_provider(),
+                capture_output=True,
+                text=True,
+                timeout=10,
+                check=False,
+            )
+        except (OSError, subprocess.TimeoutExpired) as exc:
+            log.warning("wpctl %s failed: %s", " ".join(args), exc)
+            return None
+        if result.returncode != 0:
+            log.warning("wpctl %s: %s", " ".join(args), result.stderr.strip())
+            return None
+        return result.stdout
+
+    def _node_name(self, node_id: int) -> str:
+        output = self._wpctl("inspect", str(node_id)) or ""
+        for line in output.splitlines():
+            if "node.name" in line:
+                _, _, value = line.partition("=")
+                return value.strip().strip('"')
+        return ""
+
+    def list_sinks(self) -> list[Sink]:
+        output = self._wpctl("status")
+        if output is None:
+            self._sinks = []
+            return self._sinks
+
+        sinks: list[Sink] = []
+        in_sinks = False
+        for line in output.splitlines():
+            section = _SECTION.match(line)
+            if section:
+                # Sources, Filters and Streams also carry numbered rows, so the parser
+                # has to stop at the next heading rather than read to end of output.
+                in_sinks = section.group(1).strip() == "Sinks"
+                continue
+            if not in_sinks:
+                continue
+            match = _SINK_ROW.search(line)
+            if not match:
+                continue
+            node_id = int(match.group(2))
+            sinks.append(
+                Sink(
+                    node_id=node_id,
+                    description=match.group(3).strip() or f"Sink {node_id}",
+                    node_name=self._node_name(node_id),
+                    is_default=match.group(1) == "*",
+                )
+            )
+
+        self._sinks = sinks
+        return sinks
+
+    # --- entity surface -----------------------------------------------------
+    def options(self) -> list[str]:
+        """Select options for HA: descriptions, plus the "let WirePlumber decide" entry."""
+        seen: dict[str, int] = {}
+        result = [SYSTEM_DEFAULT]
+        for sink in self._sinks:
+            label = sink.description
+            if label in seen:
+                # Two identical descriptions (e.g. a pair of matched HDMI outputs) would
+                # otherwise collapse into one unselectable option.
+                seen[label] += 1
+                label = f"{label} ({seen[label]})"
+            else:
+                seen[label] = 1
+            result.append(label)
+        return result
+
+    def current_option(self) -> str:
+        for sink in self._sinks:
+            if self.preferred_sink and sink.node_name == self.preferred_sink:
+                return sink.description
+        if self.preferred_sink:
+            # Configured but not present right now — say so rather than silently
+            # reporting whatever WirePlumber happens to be using.
+            return SYSTEM_DEFAULT
+        for sink in self._sinks:
+            if sink.is_default:
+                return sink.description
+        return SYSTEM_DEFAULT
+
+    def _find(self, option: str) -> Sink | None:
+        for sink in self._sinks:
+            if sink.description == option:
+                return sink
+        # Match the disambiguating "(2)" suffix options() may have added.
+        base = re.sub(r"\s+\(\d+\)$", "", option)
+        matches = [s for s in self._sinks if s.description == base]
+        return matches[0] if matches else None
+
+    def apply_preferred(self) -> None:
+        """Called once at startup, after the sink list has been read."""
+        if not self.preferred_sink:
+            log.info("no preferred audio sink configured; leaving WirePlumber's default")
+            return
+
+        for sink in self._sinks:
+            if sink.node_name == self.preferred_sink:
+                self._set_default(sink)
+                return
+
+        # Never fatal: a docked machine booted undocked, or an HDMI display that is off,
+        # legitimately has no such sink. The preference stays on file for next boot.
+        log.warning(
+            "preferred audio sink %r is not currently available; falling back to %s",
+            self.preferred_sink,
+            self.fallback,
+        )
+
+    def _set_default(self, sink: Sink) -> None:
+        log.info("setting default audio sink to %s (id=%s)", sink.description, sink.node_id)
+        self._wpctl("set-default", str(sink.node_id))
+
+    def select(self, option: str) -> str:
+        """Handle the HA select. Returns the option to publish back as state."""
+        option = option.strip()
+        self.list_sinks()
+
+        if option == SYSTEM_DEFAULT:
+            self.preferred_sink = ""
+            self._save()
+            log.info("audio output preference cleared; WirePlumber's default applies")
+            return self.current_option()
+
+        sink = self._find(option)
+        if sink is None:
+            log.warning("ignoring unknown audio output %r", option)
+            return self.current_option()
+
+        self._set_default(sink)
+        if sink.node_name:
+            self.preferred_sink = sink.node_name
+            self._save()
+        else:
+            # Without a node.name there is nothing stable to persist; the change still
+            # applies to this boot.
+            log.warning(
+                "sink %r has no node.name; the change applies now but will not survive a reboot",
+                sink.description,
+            )
+        return sink.description
+
+    def _save(self) -> None:
+        save_json(
+            self.config_path,
+            {"preferred_sink": self.preferred_sink, "fallback": self.fallback},
+        )
diff --git a/hosts/thin-client/agent/thinclient_agent/digest_canvas.py b/hosts/thin-client/agent/thinclient_agent/digest_canvas.py
new file mode 100644
index 0000000..4d0bd58
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/digest_canvas.py
@@ -0,0 +1,73 @@
+"""Handles "show my digest" requests on this thin client.
+
+Division of responsibility (project-plan Phase 11.8): Home Assistant resolves *who* and
+*where* — which person's wake word fired in which room, whether more than one person is
+present, and the "whose digest?" disambiguation prompt — and sends this agent an
+already-resolved request. Nothing in this file looks at presence, person, or area
+entities, and it must stay that way: duplicating that resolution locally would create a
+second, un-audited answer to "whose personal section may be shown", which is exactly the
+question the plan says must never be guessed.
+
+This module only shows what it is told to show.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from urllib.parse import urlencode
+
+from .sway_control import WS_DIGEST
+
+log = logging.getLogger(__name__)
+
+DETAIL_LEVELS = ("compact", "full")
+DEFAULT_DETAIL_LEVEL = "full"
+
+
+class DigestCanvas:
+    def __init__(self, sway, digest_web_url: str):
+        self.sway = sway
+        self.digest_web_url = (digest_web_url or "").rstrip("/")
+        self.detail_level = DEFAULT_DETAIL_LEVEL
+
+    def set_detail_level(self, level: str) -> str:
+        level = level.strip().lower()
+        if level not in DETAIL_LEVELS:
+            log.warning("ignoring unknown detail level %r", level)
+            return self.detail_level
+        self.detail_level = level
+        log.info("detail level set to %s", level)
+        return self.detail_level
+
+    def _url(self, detail_level: str, person: str | None) -> str:
+        params = {"detail_level": detail_level}
+        if person:
+            params["person"] = person
+        return f"{self.digest_web_url}/full.html?{urlencode(params)}"
+
+    def show(self, payload: str) -> None:
+        if not self.digest_web_url:
+            log.error("DIGEST_WEB_URL is not configured; cannot show the digest canvas")
+            return
+
+        request = {}
+        payload = (payload or "").strip()
+        if payload.startswith("{"):
+            try:
+                request = json.loads(payload)
+            except ValueError:
+                log.warning("could not parse digest request payload %r", payload)
+
+        detail_level = str(request.get("detail_level") or self.detail_level).lower()
+        if detail_level not in DETAIL_LEVELS:
+            detail_level = self.detail_level
+
+        # Set by the HA automation that already resolved presence. Absent means "no
+        # personal section" — never a local fallback to a default person.
+        person = request.get("person") or None
+
+        url = self._url(detail_level, person)
+        log.info("showing digest canvas (detail_level=%s, person=%s)", detail_level, bool(person))
+        self.sway.switch_workspace(WS_DIGEST)
+        self.sway.open_url(url)
diff --git a/hosts/thin-client/agent/thinclient_agent/input_control.py b/hosts/thin-client/agent/thinclient_agent/input_control.py
new file mode 100644
index 0000000..1639ae6
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/input_control.py
@@ -0,0 +1,133 @@
+"""Keyboard and pointer injection into the focused window, driven from Home Assistant.
+
+The point of this module is the Home Assistant mobile app: type a search term into the
+kiosk's Firefox, or nudge and click the pointer, without opening a VNC viewer.
+
+ydotool rather than xdotool: xdotool talks XTEST to an X server, which does not exist
+here — this is a wlroots Wayland session. ydotool goes in the other direction, writing
+to /dev/uinput as a virtual input device, so the compositor sees ordinary hardware
+events and no compositor-specific protocol is involved.
+
+SECURITY: the text entity's payload is typed verbatim, and that is the entire point of
+it — the user is asking for those characters to appear in the focused field. It is
+passed as a single argv element to subprocess with no shell, so it can be any string
+without becoming a command. Every *other* action here is a fixed enumerated constant:
+the movement buttons resolve to one of four hard-coded deltas and the click buttons to
+one of two hard-coded button codes. Nothing inbound ever becomes an argv element except
+that one string.
+
+move_relative() takes an arbitrary integer delta rather than an enumerated direction,
+which is why it is worth being explicit that it is NOT part of that inbound surface:
+mqtt_discovery.py never wires it to a topic. Its only caller is
+configs/gesture-control/gesture_pointer.py, which runs locally on this machine with no
+network input of any kind. The deltas are clamped and coerced to int here anyway, so
+the argv elements stay numeric whatever a caller passes.
+"""
+
+from __future__ import annotations
+
+import logging
+import shutil
+import subprocess
+
+log = logging.getLogger(__name__)
+
+MOVE_STEP = 20
+
+DIRECTIONS = {
+    "UP": (0, -MOVE_STEP),
+    "DOWN": (0, MOVE_STEP),
+    "LEFT": (-MOVE_STEP, 0),
+    "RIGHT": (MOVE_STEP, 0),
+}
+
+# ydotool 1.x takes a hex mask where 0x40 is "left button" and 0xC0 is
+# "left button, press and release". ydotool 0.1.x (what Debian bookworm ships) takes a
+# plain index instead: 0 left, 1 right, 2 middle. Which dialect is installed is decided
+# in _uses_daemon_dialect() below.
+CLICK_CODES = {
+    "LEFT": {"modern": "0xC0", "legacy": "0"},
+    "RIGHT": {"modern": "0xC1", "legacy": "1"},
+}
+
+MAX_TYPE_LENGTH = 255
+
+# Ceiling on a single move_relative() step. A camera gesture frame that lands badly
+# should nudge the pointer wrongly, not fling it off the far edge of the display.
+MAX_RELATIVE_STEP = 200
+
+
+class InputControl:
+    def __init__(self, env_provider):
+        self._env_provider = env_provider
+        # ydotool 1.x split the tool into a client plus a ydotoold daemon that owns
+        # /dev/uinput; 0.1.x has no daemon and opens the device itself. The presence of
+        # the daemon binary is therefore also a reliable marker of which command-line
+        # dialect this image got. See 1000-ydotool.hook.chroot.
+        self._dialect = "modern" if shutil.which("ydotoold") else "legacy"
+        self.available = shutil.which("ydotool") is not None
+        if not self.available:
+            log.warning("ydotool is not installed; the HA input-control entities will do nothing")
+        else:
+            log.info("ydotool present, using the %s command dialect", self._dialect)
+
+    def _ydotool(self, *args: str) -> bool:
+        if not self.available:
+            return False
+        try:
+            result = subprocess.run(
+                ["ydotool", *args],
+                env=self._env_provider(),
+                capture_output=True,
+                text=True,
+                timeout=15,
+                check=False,
+            )
+        except (OSError, subprocess.TimeoutExpired) as exc:
+            log.warning("ydotool %s failed: %s", args[0], exc)
+            return False
+        if result.returncode != 0:
+            log.warning("ydotool %s: %s", args[0], result.stderr.strip())
+            return False
+        return True
+
+    def type_text(self, payload: str) -> None:
+        text = payload.rstrip("\n")
+        if not text:
+            return
+        if len(text) > MAX_TYPE_LENGTH:
+            # Not a security limit — the payload is safe at any length. It is a guard
+            # against a stuck automation holding the keyboard for minutes on a machine
+            # whose display is shared with a room full of people.
+            log.warning("truncating typed text from %d to %d characters", len(text), MAX_TYPE_LENGTH)
+            text = text[:MAX_TYPE_LENGTH]
+
+        log.info("typing %d characters into the focused window", len(text))
+        # `--` so text beginning with a dash is typed rather than parsed as options.
+        # VERIFY: accepted by ydotool 1.x; if the installed 0.1.x build rejects it, drop
+        # it here — the only consequence is that leading-dash text is misread as flags.
+        self._ydotool("type", "--", text)
+
+    def move(self, direction: str) -> None:
+        delta = DIRECTIONS.get(direction.strip().upper())
+        if delta is None:
+            log.warning("ignoring unknown pointer direction %r", direction)
+            return
+        self.move_relative(*delta)
+
+    def move_relative(self, dx: int, dy: int) -> None:
+        dx = max(-MAX_RELATIVE_STEP, min(MAX_RELATIVE_STEP, int(dx)))
+        dy = max(-MAX_RELATIVE_STEP, min(MAX_RELATIVE_STEP, int(dy)))
+        if dx == 0 and dy == 0:
+            return
+        if self._dialect == "modern":
+            self._ydotool("mousemove", "-x", str(dx), "-y", str(dy))
+        else:
+            self._ydotool("mousemove", str(dx), str(dy))
+
+    def click(self, button: str) -> None:
+        code = CLICK_CODES.get(button.strip().upper())
+        if code is None:
+            log.warning("ignoring unknown mouse button %r", button)
+            return
+        self._ydotool("click", code[self._dialect])
diff --git a/hosts/thin-client/agent/thinclient_agent/main.py b/hosts/thin-client/agent/thinclient_agent/main.py
new file mode 100644
index 0000000..d780aa6
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/main.py
@@ -0,0 +1,280 @@
+"""thinclient-agent entrypoint."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import signal
+import socket
+import sys
+import threading
+from dataclasses import dataclass
+from datetime import datetime, timezone
+
+import paho.mqtt.client as mqtt
+
+from .audio_control import AudioControl
+from .digest_canvas import DETAIL_LEVELS, DigestCanvas
+from .input_control import InputControl
+from .mpris_bridge import MprisBridge
+from .mqtt_discovery import Discovery
+from .remote_desktop import RemoteDesktop
+from .sway_control import WS_DIGEST, WS_MEDIA, WS_WEB, SwayControl
+
+CONFIG_PATH = os.environ.get("THINCLIENT_AGENT_CONFIG", "/etc/thinclient-agent/config.env")
+
+CONFIG_KEYS = (
+    "MQTT_BROKER_HOST",
+    "MQTT_BROKER_PORT",
+    "MQTT_USERNAME",
+    "MQTT_PASSWORD",
+    "HA_URL",
+    "DIGEST_WEB_URL",
+    "KIOSK_USERNAME",
+    "THINCLIENT_NAME",
+)
+
+WORKSPACES = (WS_WEB, WS_DIGEST, WS_MEDIA)
+
+# Fixed, household-wide — not under this device's own thinclient/ namespace.
+# "Was the digest looked at" is a fact about the digest, not about this specific
+# machine, and digest-engine (a different physical host, see
+# digest-engine/viewed_tracker.py) has no reason to know or care which of possibly
+# several thin clients showed it.
+DIGEST_VIEWED_TOPIC = "smarthome/digest/viewed"
+
+log = logging.getLogger("thinclient-agent")
+
+
+def publish_digest_viewed(client: mqtt.Client) -> None:
+    # Retained so digest-engine's next run — which may start hours later, on a
+    # different machine — can read it without racing to be subscribed at the instant
+    # it's published.
+    payload = json.dumps(
+        {"viewed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")}
+    )
+    client.publish(DIGEST_VIEWED_TOPIC, payload, qos=1, retain=True)
+
+
+@dataclass(frozen=True)
+class App:
+    name: str
+    command: list[str]
+    process_pattern: str | None = None
+    workspace: str | None = None
+    focus_criteria: str | None = None
+    icon: str = "mdi:application"
+
+
+def load_config(path: str = CONFIG_PATH) -> dict[str, str]:
+    values: dict[str, str] = {}
+    try:
+        with open(path, encoding="utf-8") as handle:
+            for line in handle:
+                line = line.strip()
+                if not line or line.startswith("#") or "=" not in line:
+                    continue
+                key, _, value = line.partition("=")
+                values[key.strip()] = value.strip().strip('"').strip("'")
+    except OSError as exc:
+        log.warning("could not read %s (%s); falling back to the environment", path, exc)
+
+    for key in CONFIG_KEYS:
+        if key in os.environ:
+            values[key] = os.environ[key]
+
+    return values
+
+
+def build_apps(config: dict[str, str]) -> dict[str, App]:
+    digest_url = (config.get("DIGEST_WEB_URL") or "").rstrip("/")
+    return {
+        "firefox": App(
+            name="Firefox",
+            command=["/usr/local/bin/digest-browser", f"{digest_url}/full.html?detail_level=full"],
+            workspace=WS_DIGEST,
+            icon="mdi:firefox",
+        ),
+        "web_browser": App(
+            name="web browser",
+            # Separate from the digest window above: minimal chrome instead of --kiosk,
+            # its own Firefox profile, and it lands on 1:web. See configs/firefox/.
+            command=["/usr/local/bin/web-browser"],
+            workspace=WS_WEB,
+            icon="mdi:web",
+        ),
+        "steam_link": App(
+            name="Steam Link",
+            # Forced onto Xwayland: native Wayland black-screens/flickers on wlroots
+            # (project-plan Phase 11.7).
+            command=[
+                "flatpak",
+                "run",
+                "--env=SDL_VIDEODRIVER=x11",
+                "com.valvesoftware.SteamLink",
+            ],
+            process_pattern="SteamLink",
+            workspace=WS_MEDIA,
+            focus_criteria='class="steamlink"',
+            icon="mdi:steam",
+        ),
+    }
+
+
+def make_client(client_id: str) -> mqtt.Client:
+    # paho-mqtt 2.x requires an explicit callback API version; bookworm's
+    # python3-paho-mqtt is 1.6.x and has no such argument. VERSION1 is requested when
+    # available so the callback signatures below are identical under both.
+    callback_api = getattr(mqtt, "CallbackAPIVersion", None)
+    if callback_api is not None:
+        return mqtt.Client(callback_api.VERSION1, client_id=client_id)
+    return mqtt.Client(client_id=client_id)
+
+
+def main() -> int:
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+        stream=sys.stdout,
+    )
+
+    config = load_config()
+    hostname = socket.gethostname()
+    node_id = "".join(c if c.isalnum() else "_" for c in hostname).strip("_") or "thinclient"
+    friendly_name = config.get("THINCLIENT_NAME") or f"Thin client ({hostname})"
+
+    broker_host = config.get("MQTT_BROKER_HOST", "")
+    broker_port = int(config.get("MQTT_BROKER_PORT") or 1883)
+
+    sway = SwayControl()
+    canvas = DigestCanvas(sway, config.get("DIGEST_WEB_URL", ""))
+    apps = build_apps(config)
+    audio = AudioControl(sway.session_env)
+    remote = RemoteDesktop(sway)
+    keyboard = InputControl(sway.session_env)
+
+    # Applied before MQTT is even attempted: the audio preference is a local setting and
+    # must hold with the container host powered off (Phase 11.10).
+    audio.list_sinks()
+    audio.apply_preferred()
+
+    client = make_client(f"thinclient-agent-{node_id}")
+    if config.get("MQTT_USERNAME"):
+        client.username_pw_set(config["MQTT_USERNAME"], config.get("MQTT_PASSWORD") or None)
+
+    discovery = Discovery(client, node_id, friendly_name)
+    mpris = MprisBridge(discovery.publish_media_state, sway.session_env)
+
+    def on_detail_level(payload: str) -> None:
+        discovery.publish_detail_level(canvas.set_detail_level(payload))
+
+    def on_launch(key: str) -> None:
+        app = apps[key]
+        sway.launch_app(
+            app.command,
+            process_pattern=app.process_pattern,
+            workspace=app.workspace,
+            focus_criteria=app.focus_criteria,
+        )
+        if app.workspace:
+            discovery.publish_workspace(app.workspace)
+
+    def on_workspace(payload: str) -> None:
+        name = payload.strip()
+        # Enumerated, never passed through: see the security note in mqtt_discovery.py.
+        if name not in WORKSPACES:
+            log.warning("ignoring unknown workspace %r", name)
+            return
+        sway.switch_workspace(name)
+        discovery.publish_workspace(name)
+
+    def on_show_digest(payload: str) -> None:
+        canvas.show(payload)
+        discovery.publish_workspace(WS_DIGEST)
+        # Covers both triggers of this one handler: the manual "Show digest canvas"
+        # button and an HA automation's voice-resolved "play my digest" request (Phase
+        # 11.8) — both are a real, on-screen display, unlike the compact HA-dashboard
+        # iframe, which never reaches this agent at all and so never marks anything
+        # viewed. See digest-engine/viewed_tracker.py for what reads this.
+        publish_digest_viewed(client)
+
+    def on_audio_output(payload: str) -> None:
+        discovery.publish_audio_output(audio.select(payload))
+
+    def on_remote_target(payload: str) -> None:
+        discovery.publish_remote_target(remote.select(payload))
+
+    def on_type(payload: str) -> None:
+        keyboard.type_text(payload)
+
+    def on_move(direction: str) -> None:
+        keyboard.move(direction)
+
+    def on_click(button: str) -> None:
+        keyboard.click(button)
+
+    def on_connect(_client, _userdata, _flags, rc):
+        if rc != 0:
+            log.error("MQTT connection refused (rc=%s)", rc)
+            return
+        log.info("connected to MQTT broker %s:%s", broker_host, broker_port)
+        discovery.register_media_player(mpris.handle_command, mpris.set_volume)
+        discovery.register_digest(on_show_digest, on_detail_level, DETAIL_LEVELS, canvas.detail_level)
+        discovery.register_app_launchers(apps, on_launch)
+        discovery.register_workspace_select(WORKSPACES, on_workspace, WS_DIGEST)
+        # audio.apply_preferred() already ran once at startup (before MQTT was even
+        # attempted, per the comment above) — re-list here so the select's options
+        # reflect this exact moment rather than whatever was plugged in at boot.
+        audio.list_sinks()
+        discovery.register_audio_output(audio.options(), on_audio_output, audio.current_option())
+        discovery.register_remote_desktop(
+            remote.options(), on_remote_target, remote.connect, remote.disconnect, remote.current
+        )
+        discovery.register_input_control(on_type, on_move, on_click)
+        discovery.subscribe_all()
+        discovery.publish_available(True)
+
+    def on_disconnect(_client, _userdata, rc):
+        log.warning("disconnected from MQTT broker (rc=%s); paho will retry", rc)
+
+    def on_message(_client, _userdata, message):
+        discovery.dispatch(message.topic, message.payload.decode("utf-8", "replace"))
+
+    client.on_connect = on_connect
+    client.on_disconnect = on_disconnect
+    client.on_message = on_message
+    client.will_set(discovery.availability_topic, "offline", qos=1, retain=True)
+
+    stop_event = threading.Event()
+
+    def handle_signal(_signum, _frame):
+        stop_event.set()
+
+    signal.signal(signal.SIGTERM, handle_signal)
+    signal.signal(signal.SIGINT, handle_signal)
+
+    if not broker_host:
+        log.error("MQTT_BROKER_HOST is not set in %s — running without HA control", CONFIG_PATH)
+    else:
+        # connect_async + loop_start, never a blocking connect(): Phase 11.10 requires
+        # the kiosk to come up and play media with the container host powered off, so
+        # this agent must never be able to stall the session waiting on the broker.
+        client.connect_async(broker_host, broker_port, keepalive=60)
+        client.loop_start()
+
+    log.info("thinclient-agent %s started (node_id=%s)", node_id, node_id)
+    try:
+        mpris.run_forever(stop_event)
+    finally:
+        log.info("shutting down")
+        if broker_host:
+            discovery.publish_available(False)
+            client.loop_stop()
+            client.disconnect()
+
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/hosts/thin-client/agent/thinclient_agent/mpris_bridge.py b/hosts/thin-client/agent/thinclient_agent/mpris_bridge.py
new file mode 100644
index 0000000..76c5835
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/mpris_bridge.py
@@ -0,0 +1,129 @@
+"""Bridges mpv/spotifyd MPRIS state to the HA media_player entity via playerctl.
+
+playerctl subprocess calls rather than dbus-python bindings: this daemon runs as a
+system service that starts before the session bus exists and has to survive the
+compositor (and therefore every MPRIS player) coming and going. A long-lived dbus
+connection would have to be torn down and re-established around each of those events,
+whereas a `playerctl` call is stateless — if no player is up it exits non-zero and the
+bridge simply reports "off" on that tick. The cost is a poll interval instead of
+signals, which is irrelevant for a media-transport entity in Home Assistant.
+"""
+
+from __future__ import annotations
+
+import logging
+import subprocess
+import threading
+
+log = logging.getLogger(__name__)
+
+PLAYER_PRIORITY = "mpv,spotifyd,%any"
+
+_STATUS_TO_HA = {
+    "Playing": "playing",
+    "Paused": "paused",
+    "Stopped": "idle",
+}
+
+_METADATA_FORMAT = "{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}"
+
+
+class MprisBridge:
+    def __init__(self, publish_state, env_provider, poll_interval: float = 2.0):
+        self._publish_state = publish_state
+        # playerctl needs DBUS_SESSION_BUS_ADDRESS, which this system service does not
+        # inherit; SwayControl.session_env() derives it from the kiosk user's runtime dir.
+        self._env_provider = env_provider
+        self._poll_interval = poll_interval
+        self._last_state: dict | None = None
+
+    def _playerctl(self, *args: str) -> str | None:
+        try:
+            result = subprocess.run(
+                ["playerctl", "-p", PLAYER_PRIORITY, *args],
+                env=self._env_provider(),
+                capture_output=True,
+                text=True,
+                timeout=5,
+                check=False,
+            )
+        except (OSError, subprocess.TimeoutExpired) as exc:
+            log.debug("playerctl %s failed: %s", " ".join(args), exc)
+            return None
+        if result.returncode != 0:
+            return None
+        return result.stdout.strip()
+
+    def read_state(self) -> dict:
+        status = self._playerctl("status")
+        if status is None:
+            return {"state": "off"}
+
+        state = {"state": _STATUS_TO_HA.get(status, "idle")}
+
+        metadata = self._playerctl("metadata", "--format", _METADATA_FORMAT)
+        if metadata:
+            title, artist, album, length, art_url = (metadata.split("\x1f") + [""] * 5)[:5]
+            state["title"] = title
+            state["artist"] = artist
+            state["album"] = album
+            state["art_url"] = art_url
+            if length.isdigit():
+                state["duration"] = int(length) // 1_000_000
+
+        position = self._playerctl("position")
+        if position:
+            try:
+                state["position"] = int(float(position))
+            except ValueError:
+                pass
+
+        volume = self._playerctl("volume")
+        if volume:
+            try:
+                state["volume"] = round(float(volume), 3)
+            except ValueError:
+                pass
+
+        return state
+
+    def poll_once(self) -> None:
+        state = self.read_state()
+        if state != self._last_state:
+            self._last_state = state
+            self._publish_state(state)
+
+    def run_forever(self, stop_event: threading.Event) -> None:
+        while not stop_event.is_set():
+            try:
+                self.poll_once()
+            except Exception:
+                log.exception("MPRIS poll failed")
+            stop_event.wait(self._poll_interval)
+
+    # --- command side -------------------------------------------------------
+    def handle_command(self, command: str) -> None:
+        command = command.strip().upper()
+        action = {
+            "PLAY": ("play",),
+            "PAUSE": ("pause",),
+            "PLAY_PAUSE": ("play-pause",),
+            "TOGGLE": ("play-pause",),
+            "STOP": ("stop",),
+            "NEXT": ("next",),
+            "PREVIOUS": ("previous",),
+            "PREV": ("previous",),
+        }.get(command)
+
+        if action is None:
+            log.warning("ignoring unknown media command %r", command)
+            return
+
+        log.info("media command %s", command)
+        self._playerctl(*action)
+        self.poll_once()
+
+    def set_volume(self, level: float) -> None:
+        level = max(0.0, min(1.0, level))
+        self._playerctl("volume", f"{level:.3f}")
+        self.poll_once()
diff --git a/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py b/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py
new file mode 100644
index 0000000..e06c901
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/mqtt_discovery.py
@@ -0,0 +1,340 @@
+"""Home Assistant MQTT Discovery payloads and command dispatch.
+
+SECURITY BOUNDARY — this module is the entire remote-control API of the thin client.
+
+Per project-plan Phase 11.4, the local LLM is never given a network path to this
+machine. The only chain is: LLM tool call -> Home Assistant service call -> MQTT ->
+this dispatcher. That property holds only as long as this stays the sole inbound
+control surface in the codebase: no HTTP listener, no websocket server, no exposed
+Sway IPC socket, no shell endpoint. If a future feature needs a new control path, it
+belongs as another entity below, not as another listener.
+
+Consequently, every command handler here is a fixed, enumerated action. A payload
+never becomes an argv element, a shell string, or a URL host — see the launch table in
+main.py and DigestCanvas._url(), both of which build their commands from local
+constants and use the payload only to pick between known values.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Callable
+
+from . import __version__
+
+log = logging.getLogger(__name__)
+
+DISCOVERY_PREFIX = "homeassistant"
+
+
+class Discovery:
+    def __init__(self, client, node_id: str, friendly_name: str):
+        self.client = client
+        self.node_id = node_id
+        self.friendly_name = friendly_name
+        self.base = f"thinclient/{node_id}"
+        self.availability_topic = f"{self.base}/availability"
+        self.media_state_topic = f"{self.base}/media/state"
+        self.detail_level_state_topic = f"{self.base}/digest/detail_level/state"
+        self.audio_sink_state_topic = f"{self.base}/audio/sink/state"
+        self.remote_target_state_topic = f"{self.base}/remote/target/state"
+        self.input_text_state_topic = f"{self.base}/input/text/state"
+        self._handlers: dict[str, Callable[[str], None]] = {}
+
+        self.device = {
+            "identifiers": [f"thinclient_{node_id}"],
+            "name": friendly_name,
+            "manufacturer": "SmartestHome",
+            "model": "Sway thin client",
+            "sw_version": __version__,
+        }
+
+    # --- plumbing -----------------------------------------------------------
+    def _publish_config(self, component: str, object_id: str, payload: dict) -> None:
+        payload = {
+            "availability_topic": self.availability_topic,
+            "device": self.device,
+            "unique_id": f"{self.node_id}_{object_id}",
+            **payload,
+        }
+        topic = f"{DISCOVERY_PREFIX}/{component}/{self.node_id}/{object_id}/config"
+        self.client.publish(topic, json.dumps(payload), qos=1, retain=True)
+
+    def _command_topic(self, suffix: str, handler) -> str:
+        topic = f"{self.base}/{suffix}"
+        self._handlers[topic] = handler
+        return topic
+
+    def subscribe_all(self) -> None:
+        for topic in self._handlers:
+            self.client.subscribe(topic, qos=1)
+
+    def dispatch(self, topic: str, payload: str) -> None:
+        handler = self._handlers.get(topic)
+        if handler is None:
+            log.warning("no handler for %s", topic)
+            return
+        try:
+            handler(payload)
+        except Exception:
+            log.exception("handler for %s failed", topic)
+
+    def publish_available(self, available: bool = True) -> None:
+        self.client.publish(
+            self.availability_topic,
+            "online" if available else "offline",
+            qos=1,
+            retain=True,
+        )
+
+    def publish_media_state(self, state: dict) -> None:
+        self.client.publish(self.media_state_topic, json.dumps(state), qos=0, retain=True)
+
+    def publish_detail_level(self, level: str) -> None:
+        self.client.publish(self.detail_level_state_topic, level, qos=1, retain=True)
+
+    # --- entities -----------------------------------------------------------
+    def register_media_player(self, on_command, on_volume) -> None:
+        command_topic = self._command_topic("media/command", on_command)
+        volume_topic = self._command_topic("media/volume/set", lambda p: on_volume(float(p)))
+
+        # Core Home Assistant's MQTT integration has NO media_player platform — this
+        # payload is only consumed if the HACS "MQTT Media Player" custom integration
+        # is installed (see hosts/thin-client/README.md). The button/number entities
+        # below give the same transport control with stock HA, so a plain install still
+        # gets working playback control; do not remove them in favour of this one.
+        self._publish_config(
+            "media_player",
+            "media",
+            {
+                "name": "Media",
+                "state_topic": self.media_state_topic,
+                "state_template": "{{ value_json.state }}",
+                "command_topic": command_topic,
+                "volume_command_topic": volume_topic,
+                "volume_state_topic": self.media_state_topic,
+                "volume_template": "{{ value_json.volume }}",
+                "title_template": "{{ value_json.title }}",
+                "artist_template": "{{ value_json.artist }}",
+                "album_template": "{{ value_json.album }}",
+            },
+        )
+
+        for object_id, name, payload, icon in (
+            ("media_play_pause", "Play/pause", "PLAY_PAUSE", "mdi:play-pause"),
+            ("media_next", "Next track", "NEXT", "mdi:skip-next"),
+            ("media_previous", "Previous track", "PREVIOUS", "mdi:skip-previous"),
+            ("media_stop", "Stop", "STOP", "mdi:stop"),
+        ):
+            self._publish_config(
+                "button",
+                object_id,
+                {
+                    "name": name,
+                    "command_topic": command_topic,
+                    "payload_press": payload,
+                    "icon": icon,
+                },
+            )
+
+        self._publish_config(
+            "sensor",
+            "media_state",
+            {
+                "name": "Playback state",
+                "state_topic": self.media_state_topic,
+                "value_template": "{{ value_json.state }}",
+                "json_attributes_topic": self.media_state_topic,
+                "icon": "mdi:music",
+            },
+        )
+
+        self._publish_config(
+            "number",
+            "media_volume",
+            {
+                "name": "Volume",
+                "command_topic": volume_topic,
+                "state_topic": self.media_state_topic,
+                "value_template": "{{ value_json.volume }}",
+                "min": 0,
+                "max": 1,
+                "step": 0.05,
+                "mode": "slider",
+                "icon": "mdi:volume-high",
+            },
+        )
+
+    def register_digest(self, on_show, on_detail_level, detail_levels, current_level) -> None:
+        self._publish_config(
+            "button",
+            "digest_show",
+            {
+                "name": "Show digest canvas",
+                "command_topic": self._command_topic("digest/show", on_show),
+                "icon": "mdi:earth",
+            },
+        )
+
+        self._publish_config(
+            "select",
+            "digest_detail_level",
+            {
+                "name": "Digest detail level",
+                "command_topic": self._command_topic("digest/detail_level/set", on_detail_level),
+                "state_topic": self.detail_level_state_topic,
+                "options": list(detail_levels),
+                "icon": "mdi:format-list-bulleted",
+            },
+        )
+        self.publish_detail_level(current_level)
+
+    def register_app_launchers(self, apps, on_launch) -> None:
+        for key, app in apps.items():
+            self._publish_config(
+                "button",
+                f"launch_{key}",
+                {
+                    "name": f"Launch {app.name}",
+                    "command_topic": self._command_topic(
+                        f"app/{key}/launch",
+                        lambda _payload, key=key: on_launch(key),
+                    ),
+                    "icon": app.icon,
+                },
+            )
+
+    def register_workspace_select(self, workspaces, on_workspace, state_topic_value) -> None:
+        self._publish_config(
+            "select",
+            "workspace",
+            {
+                "name": "Workspace",
+                "command_topic": self._command_topic("workspace/set", on_workspace),
+                "state_topic": f"{self.base}/workspace/state",
+                "options": list(workspaces),
+                "icon": "mdi:view-dashboard",
+            },
+        )
+        self.client.publish(
+            f"{self.base}/workspace/state", state_topic_value, qos=1, retain=True
+        )
+
+    def publish_workspace(self, name: str) -> None:
+        self.client.publish(f"{self.base}/workspace/state", name, qos=1, retain=True)
+
+    def register_audio_output(self, options, on_select, current) -> None:
+        """Audio-output select. Options are WirePlumber sink *descriptions*.
+
+        Re-published on every reconnect rather than kept live: the option list comes
+        from whatever `wpctl status` shows at that moment, and a select whose options
+        changed under Home Assistant mid-session is worse than one that refreshes when
+        the agent does. See audio_control.AudioControl for the node.name mapping.
+        """
+        self._publish_config(
+            "select",
+            "audio_output",
+            {
+                "name": "Audio output",
+                "command_topic": self._command_topic("audio/sink/set", on_select),
+                "state_topic": self.audio_sink_state_topic,
+                "options": list(options),
+                "icon": "mdi:speaker",
+            },
+        )
+        self.publish_audio_output(current)
+
+    def publish_audio_output(self, option: str) -> None:
+        self.client.publish(self.audio_sink_state_topic, option, qos=1, retain=True)
+
+    def register_remote_desktop(self, targets, on_select, on_connect, on_disconnect, current) -> None:
+        self._publish_config(
+            "select",
+            "remote_target",
+            {
+                "name": "Remote desktop target",
+                "command_topic": self._command_topic("remote/target/set", on_select),
+                "state_topic": self.remote_target_state_topic,
+                "options": list(targets),
+                "icon": "mdi:remote-desktop",
+            },
+        )
+        self.publish_remote_target(current)
+
+        for object_id, name, handler, icon in (
+            ("remote_connect", "Remote desktop connect", on_connect, "mdi:lan-connect"),
+            ("remote_disconnect", "Remote desktop disconnect", on_disconnect, "mdi:lan-disconnect"),
+        ):
+            self._publish_config(
+                "button",
+                object_id,
+                {
+                    "name": name,
+                    "command_topic": self._command_topic(f"remote/{object_id}", handler),
+                    "icon": icon,
+                },
+            )
+
+    def publish_remote_target(self, name) -> None:
+        self.client.publish(self.remote_target_state_topic, name or "", qos=1, retain=True)
+
+    def register_input_control(self, on_type, on_move, on_click) -> None:
+        """Keyboard/pointer injection into the focused window.
+
+        The `text` platform is what gives the HA mobile app a real text field with a
+        submit action; a button with a payload could not carry free text, and an
+        `input_text` helper would put the field in HA's own state machine rather than on
+        this device. Its payload is the one value in this whole module that is used as
+        content rather than as a selector — see the security note in input_control.py.
+        """
+        self._publish_config(
+            "text",
+            "input_text",
+            {
+                "name": "Type text",
+                "command_topic": self._command_topic("input/text/set", on_type),
+                "state_topic": self.input_text_state_topic,
+                "min": 0,
+                "max": 255,
+                "mode": "text",
+                "icon": "mdi:keyboard",
+            },
+        )
+        self.client.publish(self.input_text_state_topic, "", qos=1, retain=True)
+
+        for object_id, name, direction, icon in (
+            ("pointer_up", "Pointer up", "UP", "mdi:arrow-up-bold"),
+            ("pointer_down", "Pointer down", "DOWN", "mdi:arrow-down-bold"),
+            ("pointer_left", "Pointer left", "LEFT", "mdi:arrow-left-bold"),
+            ("pointer_right", "Pointer right", "RIGHT", "mdi:arrow-right-bold"),
+        ):
+            self._publish_config(
+                "button",
+                object_id,
+                {
+                    "name": name,
+                    "command_topic": self._command_topic(
+                        f"input/pointer/{direction.lower()}",
+                        lambda _payload, direction=direction: on_move(direction),
+                    ),
+                    "icon": icon,
+                },
+            )
+
+        for object_id, name, button, icon in (
+            ("pointer_click", "Left click", "LEFT", "mdi:cursor-default-click"),
+            ("pointer_right_click", "Right click", "RIGHT", "mdi:cursor-default-click-outline"),
+        ):
+            self._publish_config(
+                "button",
+                object_id,
+                {
+                    "name": name,
+                    "command_topic": self._command_topic(
+                        f"input/click/{button.lower()}",
+                        lambda _payload, button=button: on_click(button),
+                    ),
+                    "icon": icon,
+                },
+            )
diff --git a/hosts/thin-client/agent/thinclient_agent/remote_desktop.py b/hosts/thin-client/agent/thinclient_agent/remote_desktop.py
new file mode 100644
index 0000000..c3b7d95
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/remote_desktop.py
@@ -0,0 +1,201 @@
+"""Outbound RDP/VNC sessions to other machines, driven from Home Assistant.
+
+The opposite direction from wayvnc: wayvnc is the *inbound* channel that lets a human
+control this thin client, this module is the thin client connecting *out* to a laptop
+or desktop elsewhere on the LAN. One client (Remmina) covers both protocols.
+
+Per the security note in mqtt_discovery.py, the payload from HA only ever selects a
+name out of the target list parsed from rdp-vnc.json. Host, port and protocol come from
+that file; a payload that does not match a known name is dropped. The generated
+.remmina profile is written to a path built from the target's index in that file, so
+even a target name full of slashes could not escape the profile directory.
+
+Credentials: the username (and Windows domain, if any) are read from a 0600 env file
+that is never committed. The password deliberately is not — see
+configs/remote-desktop/remote-desktop-credentials.env.example for why.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+import subprocess
+from dataclasses import dataclass
+
+from .runtime_state import STATE_DIR, TEMPLATE_DIR, ensure_runtime_copy, load_json
+from .sway_control import WS_WEB
+
+log = logging.getLogger(__name__)
+
+CONFIG_FILENAME = "rdp-vnc.json"
+CREDENTIALS_PATH = os.path.join(TEMPLATE_DIR, "remote-desktop-credentials.env")
+PROFILE_DIR = os.path.join(STATE_DIR, "remmina")
+
+PROTOCOLS = {"rdp": "RDP", "vnc": "VNC"}
+DEFAULT_PORTS = {"rdp": 3389, "vnc": 5900}
+
+REMMINA_APP_ID = "org.remmina.Remmina"
+
+
+@dataclass(frozen=True)
+class Target:
+    name: str
+    host: str
+    port: int
+    protocol: str
+    credentials_ref: str
+    profile_path: str
+
+
+def _read_credentials() -> dict[str, str]:
+    values: dict[str, str] = {}
+    try:
+        with open(CREDENTIALS_PATH, encoding="utf-8") as handle:
+            for line in handle:
+                line = line.strip()
+                if not line or line.startswith("#") or "=" not in line:
+                    continue
+                key, _, value = line.partition("=")
+                values[key.strip().upper()] = value.strip().strip('"').strip("'")
+    except OSError:
+        log.info(
+            "%s not present; remote-desktop profiles will be generated without a username",
+            CREDENTIALS_PATH,
+        )
+    return values
+
+
+class RemoteDesktop:
+    def __init__(self, sway):
+        self.sway = sway
+        self.config_path = ensure_runtime_copy(CONFIG_FILENAME)
+        self.targets: dict[str, Target] = {}
+        self.current: str | None = None
+        self.load()
+
+    def load(self) -> None:
+        config = load_json(self.config_path)
+        entries = config.get("targets")
+        if not isinstance(entries, list):
+            log.warning("%s has no 'targets' list; no remote-desktop entities", self.config_path)
+            entries = []
+
+        credentials = _read_credentials()
+        targets: dict[str, Target] = {}
+
+        for index, entry in enumerate(entries):
+            if not isinstance(entry, dict):
+                continue
+            name = str(entry.get("name") or "").strip()
+            host = str(entry.get("host") or "").strip()
+            protocol = str(entry.get("protocol") or "").strip().lower()
+
+            if not name or not host:
+                log.warning("skipping remote-desktop target %s: needs a name and a host", index)
+                continue
+            if protocol not in PROTOCOLS:
+                log.warning(
+                    "skipping remote-desktop target %r: protocol must be one of %s",
+                    name,
+                    ", ".join(sorted(PROTOCOLS)),
+                )
+                continue
+            if name in targets:
+                log.warning("skipping duplicate remote-desktop target %r", name)
+                continue
+
+            try:
+                port = int(entry.get("port") or DEFAULT_PORTS[protocol])
+            except (TypeError, ValueError):
+                port = DEFAULT_PORTS[protocol]
+
+            target = Target(
+                name=name,
+                host=host,
+                port=port,
+                protocol=protocol,
+                credentials_ref=str(entry.get("credentials_ref") or "").strip(),
+                # Indexed, not named: the filename must not be derived from a string a
+                # human typed into a config file, and the index is already unique.
+                profile_path=os.path.join(PROFILE_DIR, f"target-{index}.remmina"),
+            )
+            targets[name] = target
+            self._write_profile(target, credentials)
+
+        self.targets = targets
+        if self.current not in self.targets:
+            self.current = next(iter(self.targets), None)
+        log.info("loaded %d remote-desktop target(s)", len(self.targets))
+
+    def options(self) -> list[str]:
+        return list(self.targets)
+
+    def _write_profile(self, target: Target, credentials: dict[str, str]) -> None:
+        prefix = re.sub(r"[^A-Z0-9]", "_", target.credentials_ref.upper())
+        username = credentials.get(f"{prefix}_USERNAME", "") if prefix else ""
+        domain = credentials.get(f"{prefix}_DOMAIN", "") if prefix else ""
+
+        # No `password=` key. Remmina asks once and keeps it in its own store; writing
+        # one here would mean a cleartext credential for another machine sitting on an
+        # unattended kiosk.
+        profile = "\n".join(
+            (
+                "[remmina]",
+                f"name={target.name}",
+                f"protocol={PROTOCOLS[target.protocol]}",
+                f"server={target.host}:{target.port}",
+                f"username={username}",
+                f"domain={domain}",
+                "group=SmartestHome",
+                "window_maximize=1",
+                "viewmode=1",
+                "scale=1",
+                "disableclipboard=0",
+                "",
+            )
+        )
+
+        try:
+            os.makedirs(PROFILE_DIR, exist_ok=True)
+            with open(target.profile_path, "w", encoding="utf-8") as handle:
+                handle.write(profile)
+            os.chmod(target.profile_path, 0o600)
+        except OSError as exc:
+            log.warning("could not write %s: %s", target.profile_path, exc)
+
+    # --- entity surface -----------------------------------------------------
+    def select(self, payload: str) -> str | None:
+        name = payload.strip()
+        if name not in self.targets:
+            log.warning("ignoring unknown remote-desktop target %r", name)
+            return self.current
+        self.current = name
+        log.info("remote-desktop target set to %s", name)
+        return self.current
+
+    def connect(self, _payload: str = "") -> None:
+        if self.current is None:
+            log.warning("no remote-desktop target selected; nothing to connect to")
+            return
+        target = self.targets[self.current]
+        log.info("connecting to %s (%s %s:%s)", target.name, target.protocol, target.host, target.port)
+        # No process_pattern: Remmina is single-instance, and `remmina -c` against a
+        # running instance opens the connection there. Re-running it is how you switch
+        # target, so "already running, just focus" would be wrong.
+        self.sway.launch_app(["remmina", "-c", target.profile_path], workspace=WS_WEB)
+        self.sway.focus_window(f'app_id="{REMMINA_APP_ID}"')
+
+    def disconnect(self, _payload: str = "") -> None:
+        # A fixed argv, not a pattern built from anything inbound. -x matches the exact
+        # process name so a stray "remmina" substring elsewhere cannot be caught.
+        log.info("disconnecting remote desktop")
+        try:
+            subprocess.run(
+                ["pkill", "-u", str(os.getuid()), "-x", "remmina"],
+                capture_output=True,
+                timeout=5,
+                check=False,
+            )
+        except (OSError, subprocess.TimeoutExpired) as exc:
+            log.warning("could not stop remmina: %s", exc)
diff --git a/hosts/thin-client/agent/thinclient_agent/runtime_state.py b/hosts/thin-client/agent/thinclient_agent/runtime_state.py
new file mode 100644
index 0000000..98a0fdb
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/runtime_state.py
@@ -0,0 +1,73 @@
+"""Seeds writable runtime copies of the read-only config templates in the image.
+
+Two of this agent's config files (audio-config.json, rdp-vnc.json) are rewritten at
+runtime — the HA "Audio output" select has to survive a reboot, and remote-desktop
+targets have to be editable without rebuilding an ISO. Neither can live where the
+build put them: the live image's /etc is inside a squashfs.
+
+So the same split the wayvnc password already uses applies here — a committed template
+that the build bakes in read-only, plus a real file created on the booted machine that
+is never committed. The difference is that wayvnc's real file is written by a human and
+fails closed if they forget, whereas these two are seeded automatically from the
+template, because "no audio-output preference yet" is a perfectly safe state and there
+is nothing to fail closed about.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import shutil
+import tempfile
+
+log = logging.getLogger(__name__)
+
+TEMPLATE_DIR = os.environ.get("THINCLIENT_TEMPLATE_DIR", "/etc/thinclient-agent")
+STATE_DIR = os.environ.get("THINCLIENT_STATE_DIR", "/var/lib/thinclient-agent")
+
+
+def ensure_runtime_copy(filename: str) -> str:
+    """Return the writable path for `filename`, seeding it from the template if new."""
+    runtime_path = os.path.join(STATE_DIR, filename)
+    if os.path.exists(runtime_path):
+        return runtime_path
+
+    template_path = os.path.join(TEMPLATE_DIR, filename)
+    try:
+        os.makedirs(STATE_DIR, exist_ok=True)
+        shutil.copyfile(template_path, runtime_path)
+        log.info("seeded %s from %s", runtime_path, template_path)
+    except OSError as exc:
+        log.warning("could not seed %s from %s: %s", runtime_path, template_path, exc)
+
+    return runtime_path
+
+
+def load_json(path: str) -> dict:
+    try:
+        with open(path, encoding="utf-8") as handle:
+            data = json.load(handle)
+    except (OSError, ValueError) as exc:
+        log.warning("could not read %s (%s); using defaults", path, exc)
+        return {}
+    return data if isinstance(data, dict) else {}
+
+
+def save_json(path: str, data: dict) -> bool:
+    """Write atomically — a half-written config on a power cut would be worse than a
+    stale one, since these files are read unattended at boot."""
+    directory = os.path.dirname(path) or "."
+    try:
+        os.makedirs(directory, exist_ok=True)
+        with tempfile.NamedTemporaryFile(
+            "w", encoding="utf-8", dir=directory, delete=False
+        ) as handle:
+            json.dump(data, handle, indent=2)
+            handle.write("\n")
+            temp_path = handle.name
+        os.replace(temp_path, path)
+    except OSError as exc:
+        log.warning("could not write %s: %s", path, exc)
+        return False
+    return True
diff --git a/hosts/thin-client/agent/thinclient_agent/sway_control.py b/hosts/thin-client/agent/thinclient_agent/sway_control.py
new file mode 100644
index 0000000..2b14ea5
--- /dev/null
+++ b/hosts/thin-client/agent/thinclient_agent/sway_control.py
@@ -0,0 +1,116 @@
+"""Thin wrapper around swaymsg and local process launching."""
+
+from __future__ import annotations
+
+import glob
+import logging
+import os
+import subprocess
+
+log = logging.getLogger(__name__)
+
+# Contract with configs/sway/config — these strings must match the `set $ws_*` lines.
+WS_WEB = "1:web"
+WS_DIGEST = "2:digest"
+WS_MEDIA = "3:media"
+
+
+def runtime_dir() -> str:
+    return os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
+
+
+class SwayControl:
+    def __init__(self, browser_command: str = "/usr/local/bin/digest-browser"):
+        self.browser_command = browser_command
+
+    def _socket_path(self) -> str | None:
+        path = os.environ.get("SWAYSOCK")
+        if path and os.path.exists(path):
+            return path
+        # sway names the socket sway-ipc...sock, so the path changes every
+        # time sway restarts. thinclient-agent is a system service that outlives the
+        # session, so the socket is re-resolved per call instead of cached at startup.
+        matches = sorted(glob.glob(os.path.join(runtime_dir(), "sway-ipc.*.sock")))
+        return matches[-1] if matches else None
+
+    def session_env(self) -> dict[str, str]:
+        env = dict(os.environ)
+        env["XDG_RUNTIME_DIR"] = runtime_dir()
+        env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path={runtime_dir()}/bus")
+        env.setdefault("WAYLAND_DISPLAY", "wayland-1")
+        sock = self._socket_path()
+        if sock:
+            env["SWAYSOCK"] = sock
+        return env
+
+    def swaymsg(self, *args: str) -> str | None:
+        if self._socket_path() is None:
+            log.warning("no sway IPC socket found; dropping command %s", " ".join(args))
+            return None
+        try:
+            result = subprocess.run(
+                ["swaymsg", *args],
+                env=self.session_env(),
+                capture_output=True,
+                text=True,
+                timeout=10,
+                check=False,
+            )
+        except (OSError, subprocess.TimeoutExpired) as exc:
+            log.warning("swaymsg %s failed: %s", " ".join(args), exc)
+            return None
+        if result.returncode != 0:
+            log.warning("swaymsg %s: %s", " ".join(args), result.stderr.strip())
+            return None
+        return result.stdout
+
+    def switch_workspace(self, name: str) -> None:
+        log.info("switching to workspace %s", name)
+        self.swaymsg("workspace", name)
+
+    def focus_window(self, criteria: str) -> None:
+        self.swaymsg(f"[{criteria}] focus")
+
+    def is_running(self, pattern: str) -> bool:
+        try:
+            result = subprocess.run(
+                ["pgrep", "-u", str(os.getuid()), "-f", pattern],
+                capture_output=True,
+                timeout=5,
+                check=False,
+            )
+        except (OSError, subprocess.TimeoutExpired):
+            return False
+        return result.returncode == 0
+
+    def launch_app(
+        self,
+        command: list[str],
+        process_pattern: str | None = None,
+        workspace: str | None = None,
+        focus_criteria: str | None = None,
+    ) -> None:
+        if workspace:
+            self.switch_workspace(workspace)
+
+        if process_pattern and self.is_running(process_pattern):
+            log.info("%s already running; focusing instead of launching", command[0])
+            if focus_criteria:
+                self.focus_window(focus_criteria)
+            return
+
+        log.info("launching %s", " ".join(command))
+        try:
+            subprocess.Popen(
+                command,
+                env=self.session_env(),
+                stdin=subprocess.DEVNULL,
+                stdout=subprocess.DEVNULL,
+                stderr=subprocess.DEVNULL,
+                start_new_session=True,
+            )
+        except OSError as exc:
+            log.error("could not launch %s: %s", " ".join(command), exc)
+
+    def open_url(self, url: str) -> None:
+        self.launch_app([self.browser_command, url])
diff --git a/hosts/thin-client/configs/audio/audio-config.json b/hosts/thin-client/configs/audio/audio-config.json
new file mode 100644
index 0000000..23294c4
--- /dev/null
+++ b/hosts/thin-client/configs/audio/audio-config.json
@@ -0,0 +1,8 @@
+{
+  "_comment": "TEMPLATE. Baked into the image read-only at /etc/thinclient-agent/audio-config.json. On first boot thinclient_agent/audio_control.py copies it to /var/lib/thinclient-agent/audio-config.json and from then on reads and REWRITES only that copy — the HA 'Audio output' select has to survive a reboot, and a file inside a squashfs image cannot. Same split as the wayvnc password: a committed template plus a runtime-populated real file that is gitignored.",
+
+  "_comment_preferred_sink": "A WirePlumber node.name, not a description and not a numeric ID. IDs are reassigned on every boot and on every device hotplug; node.name is stable. Find yours on the booted machine with:  wpctl status  then  wpctl inspect  | grep node.name  — e.g. alsa_output.pci-0000_00_1f.3.analog-stereo. Leave it empty to just track whatever WirePlumber picks.",
+
+  "preferred_sink": "",
+  "fallback": "system-default"
+}
diff --git a/hosts/thin-client/configs/eww/eww.scss b/hosts/thin-client/configs/eww/eww.scss
new file mode 100644
index 0000000..2e87329
--- /dev/null
+++ b/hosts/thin-client/configs/eww/eww.scss
@@ -0,0 +1,50 @@
+// Styling for the now-playing widget. Installed to
+// /home//.config/eww/eww.scss by build-thin-client-iso.sh.
+//
+// eww compiles SCSS itself (bundled grass), so nothing on the image needs a Sass
+// toolchain. eww.scss rather than eww.css because every eww release supports the
+// former, while plain eww.css is only honoured by newer builds.
+
+$bg: rgba(16, 16, 20, 0.88);
+$fg: #e8e8ec;
+$muted: #9a9aa6;
+
+.np-root {
+  background-color: $bg;
+  border-radius: 12px;
+  padding: 12px;
+  color: $fg;
+  font-family: sans-serif;
+}
+
+.np-art {
+  border-radius: 8px;
+}
+
+.np-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: $fg;
+}
+
+.np-artist {
+  font-size: 13px;
+  color: $muted;
+}
+
+.np-controls {
+  margin-left: 8px;
+}
+
+.np-btn {
+  font-size: 20px;
+  color: $fg;
+  background-color: transparent;
+  border: none;
+  padding: 4px 8px;
+
+  &:hover {
+    background-color: rgba(255, 255, 255, 0.10);
+    border-radius: 6px;
+  }
+}
diff --git a/hosts/thin-client/configs/eww/eww.yuck b/hosts/thin-client/configs/eww/eww.yuck
new file mode 100644
index 0000000..1bf2407
--- /dev/null
+++ b/hosts/thin-client/configs/eww/eww.yuck
@@ -0,0 +1,54 @@
+;; Now-playing widget for the thin client. Installed to
+;; /home//.config/eww/eww.yuck by build-thin-client-iso.sh.
+;;
+;; Data source: /usr/local/bin/now-playing-json, a separate `playerctl --follow`
+;; process, NOT thinclient_agent's MprisBridge. The agent is a *system* service that
+;; deliberately outlives the compositor, whereas this widget is session-scoped and dies
+;; with sway; wiring the widget to the agent would mean either restarting the agent
+;; whenever the widget restarts, or opening a second inbound control channel into it,
+;; which the security boundary in mqtt_discovery.py rules out. The cost is one extra
+;; playerctl subscription, which is negligible.
+;;
+;; The :onclick strings below are fixed constants in this file. Nothing from MQTT, HA,
+;; or player metadata is ever interpolated into them.
+
+(defvar players "mpv,spotifyd,%any")
+
+(deflisten nowplaying
+  :initial '{"visible":false,"title":"","artist":"","art":"","controls":false}'
+  "/usr/local/bin/now-playing-json")
+
+(defwidget np-meta []
+  (box :orientation "vertical" :space-evenly false :halign "start" :valign "center" :hexpand true
+    (label :class "np-title"  :text nowplaying.title  :halign "start" :limit-width 34 :truncate true)
+    (label :class "np-artist" :text nowplaying.artist :halign "start" :limit-width 34 :truncate true)))
+
+;; Hidden rather than absent when the player exposes no transport control (some
+;; sources only report metadata) — the widget then degrades to art + text.
+(defwidget np-controls []
+  (box :class "np-controls" :orientation "horizontal" :space-evenly true :spacing 4
+       :visible {nowplaying.controls} :valign "center"
+    (button :class "np-btn" :onclick "playerctl -p ${players} previous"   "⏮")
+    (button :class "np-btn" :onclick "playerctl -p ${players} play-pause" "⏯")
+    (button :class "np-btn" :onclick "playerctl -p ${players} next"       "⏭")))
+
+(defwidget np-root []
+  (box :class "np-root" :orientation "horizontal" :space-evenly false :spacing 12
+       :visible {nowplaying.visible}
+    (image :class "np-art" :path nowplaying.art :image-width 72 :image-height 72)
+    (np-meta)
+    (np-controls)))
+
+;; :exclusive false — the widget floats over whatever is on screen and must never
+;; reserve layer-shell space, or it would shrink the fullscreen media surfaces this
+;; whole image exists to show. Visibility is owned by fullscreen-watcher.sh, which
+;; opens and closes this window; the :visible binding above only handles "nothing is
+;; playing at all".
+(defwindow now-playing
+  :monitor 0
+  :geometry (geometry :x "24px" :y "24px" :anchor "bottom left"
+                      :width "420px" :height "96px")
+  :stacking "overlay"
+  :exclusive false
+  :focusable false
+  (np-root))
diff --git a/hosts/thin-client/configs/eww/fullscreen-watcher.sh b/hosts/thin-client/configs/eww/fullscreen-watcher.sh
new file mode 100644
index 0000000..60f9d43
--- /dev/null
+++ b/hosts/thin-client/configs/eww/fullscreen-watcher.sh
@@ -0,0 +1,104 @@
+#!/bin/sh
+# Owns the visibility of the eww now-playing widget. Installed to
+# /usr/local/bin/fullscreen-watcher, started from configs/sway/config.
+#
+# The rule this enforces: the widget may only appear for *audio-only* playback with
+# nothing visual on screen. A status overlay on top of a film or a Steam Link stream is
+# worse than no status overlay at all, so every ambiguous case resolves to "hidden".
+#
+# Two independent signals, because neither alone is sufficient:
+#
+#   1. The sway tree. Catches fullscreen anything (mpv, Steam Link, a fullscreen
+#      Firefox video) via fullscreen_mode, and catches a *windowed* visual app via its
+#      app_id/class. mpv is the interesting case here: playing an audio-only file it
+#      opens no window at all, so "an mpv window exists in the tree" is already a
+#      reliable proxy for "mpv has a video stream".
+#
+#   2. mpv's IPC socket, queried for the `video-format` property. This is the direct
+#      answer to "does the current mpv track have video", and covers the case where
+#      mpv was started with --force-window and so has a window even for audio. It is
+#      best-effort: if the socket is absent (mpv not running, or configs/mpv/mpv.conf
+#      not installed) signal 1 still stands on its own.
+set -eu
+
+WINDOW=now-playing
+LOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/fullscreen-watcher.pid"
+
+# exec_always in the sway config re-runs this on every `swaymsg reload`; without this
+# guard each reload would leave another subscription and another swaymsg behind.
+if [ -f "$LOCK" ] && kill -0 "$(cat "$LOCK" 2>/dev/null)" 2>/dev/null; then
+  exit 0
+fi
+echo $$ > "$LOCK"
+
+if ! command -v eww >/dev/null 2>&1; then
+  echo "fullscreen-watcher: eww is not installed, no now-playing widget on this image." >&2
+  echo "  See live-build/config/hooks/normal/0800-eww-widget.hook.chroot." >&2
+  exit 0
+fi
+
+eww daemon >/dev/null 2>&1 || true
+
+# Anything whose presence on screen means "do not draw over this". Steam Link runs
+# under Xwayland so it appears as an X11 class, not a Wayland app_id — both are
+# checked. This is a fixed list in this file; nothing external feeds it.
+VISUAL_MATCH='^(mpv|steam|steamlink|steam_app_.*|org\.videolan\.VLC)$'
+
+visual_on_screen() {
+  swaymsg -t get_tree 2>/dev/null | jq -e --arg m "$VISUAL_MATCH" '
+    [ recurse(.nodes[]?, .floating_nodes[]?)
+      | select(.type == "con" or .type == "floating_con")
+      | select(
+          ((.fullscreen_mode // 0) != 0)
+          or ((.visible // false)
+              and (((.app_id // "") | ascii_downcase | test($m))
+                   or ((.window_properties.class // "") | ascii_downcase | test($m))))
+        )
+    ] | length > 0
+  ' >/dev/null 2>&1
+}
+
+mpv_has_video() {
+  for sock in "${HOME:-/root}/.mpv-socket" "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mpv.sock"; do
+    [ -S "$sock" ] || continue
+    python3 - "$sock" <<'PY' && return 0
+import json, socket, sys
+
+try:
+    conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+    conn.settimeout(1.0)
+    conn.connect(sys.argv[1])
+    conn.sendall(b'{"command":["get_property","video-format"]}\n')
+    reply = json.loads(conn.recv(4096).splitlines()[0])
+except Exception:
+    sys.exit(1)
+
+sys.exit(0 if reply.get("data") else 1)
+PY
+  done
+  return 1
+}
+
+apply() {
+  if visual_on_screen || mpv_has_video; then
+    eww close "$WINDOW" >/dev/null 2>&1 || true
+  else
+    eww open "$WINDOW" >/dev/null 2>&1 || true
+  fi
+}
+
+cleanup() {
+  rm -f "$LOCK"
+  eww close "$WINDOW" >/dev/null 2>&1 || true
+}
+trap cleanup EXIT INT TERM
+
+apply
+
+# The event payloads are not parsed: any window or workspace change simply triggers a
+# fresh look at the whole tree. That is one extra `swaymsg -t get_tree` per event and
+# it removes a whole class of bugs around which fields a given sway version puts in a
+# "fullscreen_mode" event.
+swaymsg -t subscribe -m '["window","workspace"]' 2>/dev/null | while read -r _event; do
+  apply
+done
diff --git a/hosts/thin-client/configs/eww/now-playing-json b/hosts/thin-client/configs/eww/now-playing-json
new file mode 100644
index 0000000..6b17cb0
--- /dev/null
+++ b/hosts/thin-client/configs/eww/now-playing-json
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""Feeds the eww now-playing widget one JSON object per MPRIS change.
+
+Installed to /usr/local/bin/now-playing-json, consumed by eww's `deflisten`.
+
+Python rather than shell: track titles are arbitrary user text and have to end up
+inside a JSON string, which is exactly the thing shell quoting gets wrong. Python 3 is
+already a hard dependency of the image (thinclient-agent runs on it), so this costs
+nothing extra.
+
+`playerctl --follow` rather than the polling loop in thinclient_agent/mpris_bridge.py:
+that module is a system service that must survive the session bus disappearing, so it
+polls. This runs inside the session and can afford to block on a subscription, which
+makes the widget update on the beat instead of up to 2s late.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import os
+import subprocess
+import sys
+import urllib.parse
+import urllib.request
+
+# Mirrors thinclient_agent.mpris_bridge.PLAYER_PRIORITY and the `playerctl -p` lists in
+# configs/sway/config and configs/eww/eww.yuck — keep the four in step.
+PLAYER_PRIORITY = "mpv,spotifyd,%any"
+
+SEP = "\x1f"
+FORMAT = SEP.join(
+    ("{{status}}", "{{title}}", "{{artist}}", "{{album}}", "{{mpris:artUrl}}")
+)
+
+CACHE_DIR = os.path.join(
+    os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"),
+    "now-playing",
+)
+
+# eww's image widget logs an error and leaves a broken box when :path does not resolve,
+# so there is always a real file to point at.
+PLACEHOLDER_PNG = base64.b64decode(
+    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+)
+
+
+def placeholder_path() -> str:
+    path = os.path.join(CACHE_DIR, "placeholder.png")
+    if not os.path.exists(path):
+        with open(path, "wb") as handle:
+            handle.write(PLACEHOLDER_PNG)
+    return path
+
+
+def cache_art(art_url: str) -> str:
+    if not art_url:
+        return ""
+
+    parsed = urllib.parse.urlparse(art_url)
+    if parsed.scheme == "file":
+        local = urllib.parse.unquote(parsed.path)
+        return local if os.path.isfile(local) else ""
+
+    if parsed.scheme not in ("http", "https"):
+        return ""
+
+    # Cached by URL digest so a repeated track does not re-fetch, and so nothing from
+    # the (remote, untrusted) URL ever reaches the filesystem as a path component.
+    target = os.path.join(CACHE_DIR, hashlib.sha256(art_url.encode()).hexdigest() + ".img")
+    if os.path.exists(target):
+        return target
+
+    try:
+        with urllib.request.urlopen(art_url, timeout=5) as response:
+            data = response.read(4 * 1024 * 1024)
+    except Exception:
+        return ""
+
+    tmp = target + ".part"
+    with open(tmp, "wb") as handle:
+        handle.write(data)
+    os.replace(tmp, target)
+    return target
+
+
+def emit(state: dict) -> None:
+    sys.stdout.write(json.dumps(state) + "\n")
+    sys.stdout.flush()
+
+
+def blank() -> dict:
+    return {
+        "visible": False,
+        "title": "",
+        "artist": "",
+        "album": "",
+        "art": placeholder_path(),
+        "controls": False,
+    }
+
+
+def parse(line: str) -> dict:
+    fields = (line.split(SEP) + [""] * 5)[:5]
+    status, title, artist, album, art_url = (f.strip() for f in fields)
+
+    if status not in ("Playing", "Paused"):
+        return blank()
+
+    art = cache_art(art_url)
+    return {
+        "visible": True,
+        "title": title or "Unknown track",
+        "artist": artist,
+        "album": album,
+        # Sources that expose no art degrade to text-only rather than showing a
+        # placeholder box; the widget hides the image when this is the 1x1 pixel.
+        "art": art or placeholder_path(),
+        # playerctl's own transport commands work for any MPRIS player that answers
+        # `status`, so controls follow the same signal rather than a separate probe.
+        "controls": True,
+    }
+
+
+def main() -> int:
+    os.makedirs(CACHE_DIR, exist_ok=True)
+    emit(blank())
+
+    process = subprocess.Popen(
+        ["playerctl", "-p", PLAYER_PRIORITY, "metadata", "--follow", "--format", FORMAT],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.DEVNULL,
+        text=True,
+    )
+
+    assert process.stdout is not None
+    for line in process.stdout:
+        line = line.rstrip("\n")
+        try:
+            emit(parse(line) if line else blank())
+        except Exception:
+            emit(blank())
+
+    # playerctl exits when the session bus goes away, i.e. when sway is going down.
+    emit(blank())
+    return process.wait()
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/hosts/thin-client/configs/firefox/policies.json b/hosts/thin-client/configs/firefox/policies.json
new file mode 100644
index 0000000..e374e0c
--- /dev/null
+++ b/hosts/thin-client/configs/firefox/policies.json
@@ -0,0 +1,63 @@
+{
+  "_comment_path": "Installed to /etc/firefox/policies/policies.json, the documented Linux location for Firefox enterprise policy. 0900-firefox.hook.chroot also links it into firefox-esr's distribution/ directory, an older location some builds read instead; whichever one the installed build honours wins and the other is ignored. Note that every key below lives OUTSIDE the 'policies' object on purpose — Firefox flags unrecognised keys *inside* it as invalid policies in about:policies.",
+
+  "_comment_extensions": "VERIFY BEFORE THE FIRST REAL BUILD. The two AMO slugs ('ublock-origin', 'sponsorblock') were confirmed against the live addons.mozilla.org listings, and /firefox/downloads/latest//latest.xpi is AMO's documented always-current download URL, so install_url should be right. The ExtensionSettings *keys* must be each extension's real add-on ID as Firefox sees it, and those were NOT verifiable from the AMO listing pages — they are widely-published values reproduced here, not checked. If an extension silently fails to install, that key is the first suspect: install the XPI by hand once, read the ID off about:debugging#/runtime/this-firefox, and correct it here.",
+
+  "_comment_kiosk_ui": "The Disable*/UserMessaging/FirefoxHome blocks all remove a prompt, tour, or nag that would otherwise sit on an unattended screen in a shared room with nobody there to dismiss it.",
+
+  "_comment_updates": "The browser is part of the image and is replaced by rebuilding and reflashing it, so in-browser updates would only produce version drift between rooms plus a restart banner nobody is there to click.",
+
+  "policies": {
+    "ExtensionSettings": {
+      "uBlock0@raymondhill.net": {
+        "installation_mode": "force_installed",
+        "install_url": "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi",
+        "default_area": "menupanel"
+      },
+      "sponsorBlocker@ajay.app": {
+        "installation_mode": "force_installed",
+        "install_url": "https://addons.mozilla.org/firefox/downloads/latest/sponsorblock/latest.xpi",
+        "default_area": "menupanel"
+      }
+    },
+
+    "DisableProfileImport": true,
+    "DisableProfileRefresh": true,
+    "DisableFirefoxAccounts": true,
+    "DisableFirefoxStudies": true,
+    "DisableTelemetry": true,
+    "DisablePocket": true,
+    "DisableFeedbackCommands": true,
+    "DisableSetDesktopBackground": true,
+    "DontCheckDefaultBrowser": true,
+    "NoDefaultBookmarks": true,
+    "OfferToSaveLogins": false,
+    "PasswordManagerEnabled": false,
+    "PromptForDownloadLocation": false,
+
+    "AppAutoUpdate": false,
+    "DisableAppUpdate": true,
+
+    "UserMessaging": {
+      "WhatsNew": false,
+      "ExtensionRecommendations": false,
+      "FeatureRecommendations": false,
+      "UrlbarInterventions": false,
+      "SkipOnboarding": true,
+      "MoreFromMozilla": false
+    },
+
+    "FirefoxHome": {
+      "Search": true,
+      "TopSites": true,
+      "SponsoredTopSites": false,
+      "Highlights": false,
+      "Pocket": false,
+      "SponsoredPocket": false,
+      "Snippets": false
+    },
+
+    "OverrideFirstRunPage": "",
+    "OverridePostUpdatePage": ""
+  }
+}
diff --git a/hosts/thin-client/configs/firefox/user.js b/hosts/thin-client/configs/firefox/user.js
new file mode 100644
index 0000000..49fe6f0
--- /dev/null
+++ b/hosts/thin-client/configs/firefox/user.js
@@ -0,0 +1,50 @@
+// Prefs for the thin client's Firefox profiles. Installed by
+// build-thin-client-iso.sh into both /home//.mozilla/firefox/digest
+// (the --kiosk digest canvas) and .../web (the browsable window with minimal chrome).
+//
+// user.js rather than prefs.js: user.js is re-applied to prefs.js on every startup, so
+// nothing a stray click changes at runtime survives a restart. That matters on a kiosk
+// nobody logs into to fix things.
+//
+// Anything expressible as enterprise policy lives in policies.json instead — policy is
+// enforced and shows up in about:policies, whereas a pref is merely a default. The
+// prefs here are the ones with no policy equivalent.
+
+// The only reason userChrome.css is read at all. Without this the stylesheet in
+// chrome/ is silently ignored and the window comes up with full default chrome.
+user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
+
+// First-run / post-update interstitials. An unattended screen has nobody to close them.
+user_pref("browser.startup.homepage_override.mstone", "ignore");
+user_pref("browser.aboutwelcome.enabled", false);
+user_pref("browser.messaging-system.whatsNewPanel.enabled", false);
+user_pref("trailhead.firstrun.didSeeAboutWelcome", true);
+user_pref("datareporting.policy.firstRunURL", "");
+user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true);
+
+// Session restore prompts after a power cut would leave the room's display stuck on a
+// "restore your session?" page instead of the digest.
+user_pref("browser.sessionstore.resume_from_crash", false);
+user_pref("browser.tabs.warnOnClose", false);
+user_pref("browser.tabs.warnOnCloseOtherTabs", false);
+user_pref("browser.warnOnQuit", false);
+
+// No permission doorhangers on a display nobody is standing at. Autoplay is allowed
+// because the digest canvas and embedded video are the point of the machine.
+user_pref("permissions.default.desktop-notification", 2);
+user_pref("permissions.default.geo", 2);
+user_pref("media.autoplay.default", 0);
+user_pref("media.autoplay.blocking_policy", 0);
+
+// Wayland-native rather than Xwayland, matching MOZ_ENABLE_WAYLAND in kiosk-session.
+// VA-API is left at its default: no thin-client hardware has been chosen yet
+// (project-plan §4 #7), so forcing hardware decode here could just as easily produce a
+// black video surface as a working one.
+user_pref("gfx.webrender.all", true);
+
+// The address bar in the minimal-chrome window is for typing a URL, not for a dropdown
+// of suggestions covering the page.
+user_pref("browser.urlbar.suggest.topsites", false);
+user_pref("browser.urlbar.suggest.quicksuggest.sponsored", false);
+user_pref("browser.newtabpage.activity-stream.showSponsored", false);
+user_pref("browser.newtabpage.activity-stream.showSponsoredTopSites", false);
diff --git a/hosts/thin-client/configs/firefox/userChrome.css b/hosts/thin-client/configs/firefox/userChrome.css
new file mode 100644
index 0000000..9fb1547
--- /dev/null
+++ b/hosts/thin-client/configs/firefox/userChrome.css
@@ -0,0 +1,64 @@
+/* Minimal browser chrome for the thin client's browsable Firefox window.
+ * Installed to /chrome/userChrome.css by build-thin-client-iso.sh, and only
+ * read because user.js sets toolkit.legacyUserProfileCustomizations.stylesheets=true.
+ *
+ * What survives: back, forward, reload, and the address bar. Everything else goes.
+ * That set is deliberate — those four are what someone driving this machine from the
+ * Home Assistant mobile app (via ydotool, see thinclient_agent/input_control.py) or
+ * over wayvnc actually needs, and every extra control is one more thing to mis-click
+ * on a screen that has no keyboard in front of it.
+ *
+ * This file is also installed into the digest profile, where it does nothing: that
+ * window runs with --kiosk, which already draws no chrome at all.
+ */
+
+/* Tab strip. The kiosk browses one page at a time; a tab bar on a shared display just
+ * accumulates whatever the last person left open. */
+#TabsToolbar,
+#tabbrowser-tabs,
+#alltabs-button {
+  visibility: collapse !important;
+}
+
+/* Menu bar and bookmarks bar. */
+#toolbar-menubar,
+#PersonalToolbar,
+#PlacesToolbar {
+  visibility: collapse !important;
+}
+
+/* Everything on the nav bar except back / forward / reload / the address bar. */
+#home-button,
+#library-button,
+#sidebar-button,
+#fxa-toolbar-menu-button,
+#unified-extensions-button,
+#PanelUI-button,
+#downloads-button,
+#save-to-pocket-button,
+#pageActionButton,
+#star-button-box,
+#reader-mode-button,
+#customizableui-special-spring1,
+#customizableui-special-spring2 {
+  display: none !important;
+}
+
+/* The urlbar keeps its identity box (padlock) — dropping it would hide the only
+ * on-screen signal that a page is or is not https. */
+#urlbar-container {
+  min-width: 0 !important;
+}
+
+#nav-bar {
+  border: none !important;
+  box-shadow: none !important;
+}
+
+/* Titlebar spacers left over once the tab strip is collapsed. */
+.titlebar-buttonbox-container,
+.titlebar-spacer {
+  display: none !important;
+}
+
+/* Findbar and notification popups stay: they are transient and user-initiated. */
diff --git a/hosts/thin-client/configs/firefox/web-browser b/hosts/thin-client/configs/firefox/web-browser
new file mode 100644
index 0000000..77ced0c
--- /dev/null
+++ b/hosts/thin-client/configs/firefox/web-browser
@@ -0,0 +1,40 @@
+#!/bin/sh
+# Opens the browsable Firefox window — minimal chrome, not kiosk mode.
+# Installed to /usr/local/bin/web-browser.
+#
+# Separate from /usr/local/bin/digest-browser, and on a separate profile, for two
+# reasons. First, the two windows want opposite chrome: the digest canvas runs --kiosk
+# with no controls at all, this one keeps back/forward/reload/address bar via
+# userChrome.css. Second, one Firefox profile can only be open in one process, so
+# sharing a profile would mean opening the browser closed the digest, and vice versa.
+#
+# Both profiles get the same user.js, userChrome.css and enterprise policies (uBlock
+# Origin, SponsorBlock), so extensions and prefs behave identically in each.
+set -eu
+
+PROFILE_DIR="${HOME:-/home/$(id -un)}/.mozilla/firefox/web"
+
+if command -v firefox-esr >/dev/null 2>&1; then
+  FIREFOX=firefox-esr
+else
+  FIREFOX=firefox
+fi
+
+mkdir -p "$PROFILE_DIR/chrome"
+
+# Re-copied on every launch rather than once at build time: this is a locked-down kiosk
+# profile with no interactive customisation expected, so keeping it in lockstep with
+# /etc/thinclient-firefox/ (edited by rebuilding the image) is simpler than a one-shot
+# seed that could drift after a userChrome.css update.
+cp /etc/thinclient-firefox/userChrome.css "$PROFILE_DIR/chrome/userChrome.css" 2>/dev/null || true
+cp /etc/thinclient-firefox/user.js        "$PROFILE_DIR/user.js"               2>/dev/null || true
+
+# Focus an already-open window rather than stacking a second one: unlike the digest
+# canvas, this window holds state (history, a half-typed URL, a logged-in page) that a
+# kill-and-relaunch would throw away.
+if pgrep -u "$(id -u)" -f "$FIREFOX .*--profile $PROFILE_DIR" >/dev/null 2>&1; then
+  [ -n "${1:-}" ] && exec "$FIREFOX" --profile "$PROFILE_DIR" --new-tab "$1"
+  exit 0
+fi
+
+exec "$FIREFOX" --profile "$PROFILE_DIR" --new-instance --new-window "${1:-about:blank}"
diff --git a/hosts/thin-client/configs/gesture-control/gesture-config.json b/hosts/thin-client/configs/gesture-control/gesture-config.json
new file mode 100644
index 0000000..4ee84a2
--- /dev/null
+++ b/hosts/thin-client/configs/gesture-control/gesture-config.json
@@ -0,0 +1,30 @@
+{
+  "_comment": "TEMPLATE. Baked into the image read-only at /etc/thinclient-agent/gesture-config.json. On first boot configs/gesture-control/gesture_pointer.py copies it to /var/lib/thinclient-agent/gesture-config.json (thinclient_agent/runtime_state.py, the same split audio-config.json and rdp-vnc.json use) and reads only that copy from then on, so the camera can be turned on and off on a running machine without rebuilding an ISO.",
+
+  "_comment_privacy": "'enabled': false is the whole point of this file. While it is false the camera device is NEVER OPENED — gesture_pointer.py checks this before it imports OpenCV or MediaPipe at all, so a disabled image does not merely ignore the camera, it never touches the video stack. Turning it on means a camera continuously captures and analyses video of this room for as long as the session is up. That is a per-room decision, exactly like the microphone in the wyoming-satellite rooms (project-plan Phase 11.8). See the privacy section in hosts/thin-client/README.md before flipping it.",
+
+  "enabled": false,
+
+  "_comment_camera": "A V4L2 device path, not an index — indices are reassigned across reboots and hotplugs, the same trap audio-config.json's preferred_sink documents for WirePlumber IDs. Find yours on the booted machine with `v4l2-ctl --list-devices`. If the box has an internal camera as well as a USB one, being explicit here is what stops the wrong one being opened.",
+  "camera_device": "/dev/video0",
+
+  "_comment_capture": "640x480 is what the hand model wants anyway; capturing higher and downscaling only costs CPU. capture_fps is what the camera is asked to deliver, inference_fps is how often a frame is actually run through the model — frames in between are read and dropped to keep the V4L2 queue drained rather than letting a backlog of stale frames build up latency. Inference is by far the expensive half, so inference_fps is the knob to turn down if the CPU load on the real hardware is too high (see README).",
+  "capture_fps": 30,
+  "inference_fps": 12,
+  "frame_width": 640,
+  "frame_height": 480,
+
+  "_comment_mirror": "true when the camera faces the user (the normal wall/display-mounted case): moving your hand right should move the pointer right, and an unmirrored front-facing camera does the opposite.",
+  "mirror": true,
+
+  "_comment_pointer": "Velocity control, not trackpad control: the hand's offset from the centre of the frame sets a pointer SPEED, so holding still in the middle stops the pointer and you never run out of frame. dead_zone is that centre rest area in normalised frame units (0.0-0.5) — below it nothing moves at all, which is what stops an idle hand drifting the pointer across the screen. pointer_speed is pixels per second at the edge of the frame.",
+  "dead_zone": 0.08,
+  "pointer_speed": 900,
+
+  "_comment_click": "A fist has to be held for fist_hold_seconds before it clicks, and the hand has to open again before another click can fire — a single mis-detected frame must never be able to click something. click_cooldown_seconds is a second floor under the repeat rate. Raise fist_hold_seconds if the false-positive click rate on the real camera is annoying.",
+  "fist_hold_seconds": 0.4,
+  "click_cooldown_seconds": 1.0,
+
+  "_comment_model": "Downloaded by 1100-gesture-control.hook.chroot at build time. If the file is missing, gesture_pointer.py logs and exits instead of starting — nothing else in the session is affected.",
+  "model_path": "/opt/gesture-control/hand_landmarker.task"
+}
diff --git a/hosts/thin-client/configs/gesture-control/gesture-control.sh b/hosts/thin-client/configs/gesture-control/gesture-control.sh
new file mode 100755
index 0000000..6517fc2
--- /dev/null
+++ b/hosts/thin-client/configs/gesture-control/gesture-control.sh
@@ -0,0 +1,50 @@
+#!/bin/sh
+# Starts the camera gesture pointer. Installed to /usr/local/bin/gesture-control,
+# launched from configs/sway/config.
+#
+# Session-scoped (a sway exec) rather than a systemd unit, for two reasons that point
+# the same way:
+#
+#   1. Privacy. The only thing gesture control can do is move and click the pointer of
+#      the on-screen session. A system unit would hold /dev/video0 open from boot to
+#      shutdown, including while no session exists and there is nothing to point at —
+#      which is exactly the wrong default for a camera in someone's living room. Tying
+#      the camera's lifetime to the session's means "screen is up" and "camera is open"
+#      cannot drift apart.
+#
+#   2. Blast radius. Continuous ML inference is a different failure profile from
+#      thinclient-agent, which is the machine's SOLE MQTT control surface and must stay
+#      reliable (see the security-boundary note in thinclient_agent/mqtt_discovery.py).
+#      A crash here, or a camera that wedges, must not be able to take HA control of the
+#      room down with it. This is the same call configs/eww/fullscreen-watcher.sh makes
+#      and for the same reason — a separate process, not another job inside the daemon.
+#
+# The runtime "enabled" flag is deliberately NOT checked here. gesture_pointer.py checks
+# it itself, before it imports OpenCV or MediaPipe, so there is exactly one place that
+# decides whether the camera is opened rather than two that can disagree.
+set -eu
+
+VENV=/opt/gesture-control/venv
+LOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gesture-control.pid"
+
+# exec_always in the sway config re-runs this on every `swaymsg reload`; without this
+# guard each reload would leave another process holding the camera.
+if [ -f "$LOCK" ] && kill -0 "$(cat "$LOCK" 2>/dev/null)" 2>/dev/null; then
+  exit 0
+fi
+echo $$ > "$LOCK"
+trap 'rm -f "$LOCK"' EXIT INT TERM
+
+if [ ! -x "$VENV/bin/python3" ]; then
+  echo "gesture-control: not installed on this image (ENABLE_GESTURE_CONTROL was false" >&2
+  echo "  at build time). See live-build/config/hooks/normal/1100-gesture-control.hook.chroot." >&2
+  exit 0
+fi
+
+# thinclient_agent.input_control and .runtime_state are stdlib-only and are imported
+# from inside this venv rather than duplicated — one ydotool call path for the whole
+# image, not a second one that drifts.
+PYTHONPATH=/opt/thinclient-agent
+export PYTHONPATH
+
+exec "$VENV/bin/python3" /opt/gesture-control/gesture_pointer.py
diff --git a/hosts/thin-client/configs/gesture-control/gesture_pointer.py b/hosts/thin-client/configs/gesture-control/gesture_pointer.py
new file mode 100644
index 0000000..caa480c
--- /dev/null
+++ b/hosts/thin-client/configs/gesture-control/gesture_pointer.py
@@ -0,0 +1,238 @@
+"""Camera hand-gesture pointer control. Installed to /opt/gesture-control/.
+
+Open hand moves the pointer, closed fist clicks. Started by /usr/local/bin/gesture-control
+from the sway config; see that wrapper for why it is a session process.
+
+PRIVACY, and the reason the structure of this file looks the way it does: enabling this
+means a camera continuously captures and analyses video of the room. main() therefore
+reads gesture-config.json and returns before OpenCV or MediaPipe are imported at all —
+the "enabled": false default does not mean the camera is opened and its frames dropped,
+it means the video stack is never loaded and /dev/video* is never opened. Keep the
+imports where they are.
+
+Why this is not a module inside thinclient_agent/, despite reusing two of its modules:
+that package is installed to /opt/thinclient-agent and runs on the SYSTEM interpreter
+against apt's python3-paho-mqtt. MediaPipe is PyPI-only and lives in its own venv
+(1100-gesture-control.hook.chroot, same PEP 668 reasoning as the voice satellite). A
+module sitting in a package whose interpreter cannot import its own dependencies would
+be a trap. The two modules it does import — input_control and runtime_state — are
+stdlib-only, so they load fine from inside the venv via PYTHONPATH.
+
+Why velocity control rather than trackpad-style frame-to-frame deltas: at the inference
+rates this hardware can sustain (~10-15 fps, see README) a delta model is both jittery
+and runs out of frame — you would have to lift and re-place your hand like a mouse. Hand
+offset from the centre of the frame driving a pointer SPEED instead is self-recentering,
+has an obvious rest state (hand in the middle = pointer stopped), and degrades into
+"slightly slower pointer" rather than "wrong pointer" when frames are dropped.
+
+The pointer anchor is the middle-finger knuckle (landmark 9), not a fingertip: it barely
+moves as the fingers curl, so the open-hand-to-fist transition does not drag the pointer
+off whatever you were about to click.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+import os
+import sys
+import time
+
+from thinclient_agent.input_control import InputControl
+from thinclient_agent.runtime_state import ensure_runtime_copy, load_json
+
+log = logging.getLogger("gesture-control")
+
+CONFIG_FILENAME = "gesture-config.json"
+
+WRIST = 0
+PALM_ANCHOR = 9
+FINGER_TIPS = (8, 12, 16, 20)
+FINGER_PIPS = (6, 10, 14, 18)
+
+# Curl is measured per finger as "is the tip nearer the wrist than its middle joint",
+# which is scale- and rotation-invariant and so needs no calibration for how far away
+# the person is standing. The gap between the two thresholds is deliberate: a hand
+# somewhere between the two states is neither, and does nothing.
+FIST_MIN_CURLED = 4
+OPEN_MAX_CURLED = 1
+
+
+def _distance(a, b) -> float:
+    return math.hypot(a.x - b.x, a.y - b.y)
+
+
+def _curled_fingers(landmarks) -> int:
+    wrist = landmarks[WRIST]
+    return sum(
+        1
+        for tip, pip in zip(FINGER_TIPS, FINGER_PIPS)
+        if _distance(landmarks[tip], wrist) < _distance(landmarks[pip], wrist)
+    )
+
+
+class GesturePointer:
+    def __init__(self, config: dict, input_control: InputControl):
+        self._input = input_control
+        self._dead_zone = float(config.get("dead_zone") or 0.08)
+        self._speed = float(config.get("pointer_speed") or 900)
+        self._mirror = bool(config.get("mirror", True))
+        self._fist_hold = float(config.get("fist_hold_seconds") or 0.4)
+        self._cooldown = float(config.get("click_cooldown_seconds") or 1.0)
+        self._fist_since: float | None = None
+        self._click_armed = True
+        # -inf, not 0.0: these are time.monotonic() values, which are uptime-relative, so
+        # 0.0 would silently swallow the first click of a session started soon after boot.
+        self._last_click = float("-inf")
+
+    def _axis_delta(self, normalised: float, elapsed: float) -> float:
+        offset = normalised - 0.5
+        magnitude = abs(offset) - self._dead_zone
+        if magnitude <= 0:
+            return 0.0
+        # Rescaled so the speed ramps from zero at the edge of the dead zone up to the
+        # full configured speed at the frame edge, rather than jumping to a fraction of
+        # it the moment the dead zone is crossed.
+        travel = max(0.5 - self._dead_zone, 1e-6)
+        return math.copysign(magnitude / travel, offset) * self._speed * elapsed
+
+    def handle(self, landmarks, now: float, elapsed: float) -> None:
+        if landmarks is None:
+            self._fist_since = None
+            self._click_armed = True
+            return
+
+        curled = _curled_fingers(landmarks)
+
+        if curled >= FIST_MIN_CURLED:
+            # No movement while the fist is closed: a click that drifts the pointer
+            # between the press and whatever the user was aiming at is worse than a
+            # click that does not fire.
+            if self._fist_since is None:
+                self._fist_since = now
+            elif (
+                self._click_armed
+                and now - self._fist_since >= self._fist_hold
+                and now - self._last_click >= self._cooldown
+            ):
+                log.info("fist held, clicking")
+                self._input.click("LEFT")
+                self._last_click = now
+                self._click_armed = False
+            return
+
+        self._fist_since = None
+        if curled <= OPEN_MAX_CURLED:
+            # Re-arming only on a clearly open hand, not merely on "not a fist", is what
+            # makes a held fist one click instead of a repeat.
+            self._click_armed = True
+            anchor = landmarks[PALM_ANCHOR]
+            x = 1.0 - anchor.x if self._mirror else anchor.x
+            self._input.move_relative(
+                self._axis_delta(x, elapsed), self._axis_delta(anchor.y, elapsed)
+            )
+
+
+def run(config: dict) -> int:
+    # Imported here rather than at module scope so that the disabled default in main()
+    # never loads the video stack. See the module docstring.
+    import cv2
+    import mediapipe as mp
+    from mediapipe.tasks import python as mp_python
+    from mediapipe.tasks.python import vision as mp_vision
+
+    model_path = str(config.get("model_path") or "/opt/gesture-control/hand_landmarker.task")
+    device = str(config.get("camera_device") or "/dev/video0")
+
+    capture = cv2.VideoCapture(device, cv2.CAP_V4L2)
+    if not capture.isOpened():
+        log.error("could not open %s — is a camera plugged in and is this user in the "
+                  "'video' group?", device)
+        return 1
+
+    capture.set(cv2.CAP_PROP_FRAME_WIDTH, int(config.get("frame_width") or 640))
+    capture.set(cv2.CAP_PROP_FRAME_HEIGHT, int(config.get("frame_height") or 480))
+    capture.set(cv2.CAP_PROP_FPS, int(config.get("capture_fps") or 30))
+    capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
+
+    inference_fps = float(config.get("inference_fps") or 12)
+    inference_period = 1.0 / inference_fps if inference_fps > 0 else 0.0
+
+    landmarker = mp_vision.HandLandmarker.create_from_options(
+        mp_vision.HandLandmarkerOptions(
+            base_options=mp_python.BaseOptions(model_asset_path=model_path),
+            # VIDEO rather than LIVE_STREAM: LIVE_STREAM hands results back on a callback
+            # thread, which buys nothing here because the loop below is already the only
+            # consumer and is deliberately rate-limited.
+            running_mode=mp_vision.RunningMode.VIDEO,
+            num_hands=1,
+            min_hand_detection_confidence=0.6,
+            min_hand_presence_confidence=0.6,
+            min_tracking_confidence=0.5,
+        )
+    )
+
+    # Plain os.environ, not SwayControl.session_env(): unlike thinclient-agent, which is
+    # a system service outside the session and has to reconstruct SWAYSOCK/XDG_RUNTIME_DIR
+    # by hand, this process is started by sway itself and already has them.
+    pointer = GesturePointer(config, InputControl(os.environ.copy))
+    log.info("gesture control running on %s at ~%.0f inference fps", device, inference_fps)
+
+    # Seeded with the current time rather than 0, so the first analysed frame gets a
+    # sane elapsed and cannot start the session by flinging the pointer a clamped step.
+    last_inference = time.monotonic()
+    try:
+        while True:
+            # Every frame is read even though most are discarded: leaving them queued in
+            # V4L2 would mean the frame that does get analysed is progressively older
+            # than the hand actually in front of the camera.
+            ok, frame = capture.read()
+            if not ok:
+                log.warning("camera read failed; stopping")
+                return 1
+
+            now = time.monotonic()
+            if now - last_inference < inference_period:
+                continue
+            elapsed = min(now - last_inference, 0.5)
+            last_inference = now
+
+            image = mp.Image(
+                image_format=mp.ImageFormat.SRGB,
+                data=cv2.cvtColor(frame, cv2.COLOR_BGR2RGB),
+            )
+            result = landmarker.detect_for_video(image, int(now * 1000))
+            hands = getattr(result, "hand_landmarks", None) or []
+            pointer.handle(hands[0] if hands else None, now, elapsed)
+    except KeyboardInterrupt:
+        return 0
+    finally:
+        capture.release()
+        landmarker.close()
+
+
+def main() -> int:
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+        stream=sys.stdout,
+    )
+
+    config = load_json(ensure_runtime_copy(CONFIG_FILENAME))
+    if config.get("enabled") is not True:
+        log.info(
+            "gesture control is disabled (the default) — the camera will not be opened. "
+            "Set \"enabled\": true in /var/lib/thinclient-agent/%s to turn it on.",
+            CONFIG_FILENAME,
+        )
+        return 0
+
+    log.warning(
+        "gesture control is ENABLED — a camera is about to start continuously capturing "
+        "and analysing video of this room for as long as this session is up."
+    )
+    return run(config)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/hosts/thin-client/configs/greetd/config.toml b/hosts/thin-client/configs/greetd/config.toml
new file mode 100644
index 0000000..53077de
--- /dev/null
+++ b/hosts/thin-client/configs/greetd/config.toml
@@ -0,0 +1,21 @@
+# greetd — autologin straight into the kiosk Sway session, no greeter UI.
+# Installed to /etc/greetd/config.toml by build-thin-client-iso.sh, which substitutes
+# @KIOSK_USERNAME@ with its own KIOSK_USERNAME variable on the way in.
+#
+# Schema note (getting these key names wrong fails silently — you get a black VT with
+# no login, and greetd logs nothing obvious): the table names are exactly [terminal]
+# and [default_session], and the session keys are exactly `command` and `user`.
+#
+# Why [default_session] and not [initial_session]: [initial_session] fires once, on
+# the first greetd start after boot, and greetd falls back to [default_session] the
+# moment that session ends. For an always-on kiosk that would mean one crash or one
+# `swaymsg exit` drops the machine to whatever [default_session] is (by default an
+# agreety login prompt). Pointing [default_session] itself at the kiosk user makes the
+# autologin permanent and self-healing. [initial_session] is deliberately absent.
+
+[terminal]
+vt = 1
+
+[default_session]
+command = "/usr/local/bin/kiosk-session"
+user = "@KIOSK_USERNAME@"
diff --git a/hosts/thin-client/configs/greetd/kiosk-session b/hosts/thin-client/configs/greetd/kiosk-session
new file mode 100755
index 0000000..5c2ad6a
--- /dev/null
+++ b/hosts/thin-client/configs/greetd/kiosk-session
@@ -0,0 +1,21 @@
+#!/bin/sh
+# greetd's default_session command. Installed to /usr/local/bin/kiosk-session.
+#
+# Exists so that MQTT_BROKER_HOST / HA_URL / DIGEST_WEB_URL are in Sway's environment:
+# sway's config file has no way to read an env file itself, but every `exec` line it
+# runs inherits this process's environment, so sourcing here is what lets the Firefox
+# kiosk `exec` point at $DIGEST_WEB_URL without templating the sway config.
+set -eu
+
+[ -r /etc/thinclient-agent/config.env ] && . /etc/thinclient-agent/config.env
+export MQTT_BROKER_HOST HA_URL DIGEST_WEB_URL
+
+export XDG_CURRENT_DESKTOP=sway
+export XDG_SESSION_TYPE=wayland
+export XDG_SESSION_DESKTOP=sway
+export MOZ_ENABLE_WAYLAND=1
+
+: "${XDG_RUNTIME_DIR:=/run/user/$(id -u)}"
+export XDG_RUNTIME_DIR
+
+exec sway
diff --git a/hosts/thin-client/configs/idle-gallery/gallery-credentials.example b/hosts/thin-client/configs/idle-gallery/gallery-credentials.example
new file mode 100644
index 0000000..691472a
--- /dev/null
+++ b/hosts/thin-client/configs/idle-gallery/gallery-credentials.example
@@ -0,0 +1,11 @@
+# Copy to /etc/thinclient-agent/gallery-credentials on the booted machine and
+# `chmod 600` it — never baked into the image, same handling as the wayvnc password
+# and the outbound remote-desktop credentials (see hosts/thin-client/README.md).
+#
+# mount.cifs's `credentials=` file format: plain key=value, no quoting, no shell
+# expansion. Must match GALLERY_SMB_USERNAME/GALLERY_SMB_PASSWORD in
+# hosts/container-host/scripts/setup-container-host.sh exactly — this project has no
+# way to push a secret from that script to a thin client, so both sides are set by
+# hand from the same value.
+username=gallery
+password=CHANGEME
diff --git a/hosts/thin-client/configs/idle-gallery/idle-gallery.sh b/hosts/thin-client/configs/idle-gallery/idle-gallery.sh
new file mode 100644
index 0000000..52ac9cc
--- /dev/null
+++ b/hosts/thin-client/configs/idle-gallery/idle-gallery.sh
@@ -0,0 +1,95 @@
+#!/bin/sh
+# Idle-timeout photo slideshow. Installed to /usr/local/bin/idle-gallery, invoked
+# directly by swayidle's `timeout`/`resume` commands in configs/sway/config — not a
+# sway `exec_always`, so the PID-lock-guard idiom in fullscreen-watcher.sh and
+# gesture-control.sh (needed there to survive repeated `swaymsg reload`) does not
+# apply here; swayidle only ever calls this on its own timeout/resume edges, so plain
+# idempotent checks (`mountpoint -q`, `pgrep -f`) are enough.
+#
+# Two-tier fallback, both ways: no gallery credentials configured, an unreachable
+# share, or an empty share must all fall back to the plain output-blank that this
+# script's caller (configs/sway/config) previously did unconditionally — a half-lit
+# fullscreen error is worse than blanking, and this is the ONLY thing standing between
+# an idle kiosk and screen burn-in, so it must never just silently do nothing.
+set -eu
+
+CONFIG_ENV=/etc/thinclient-agent/config.env
+CREDENTIALS=/etc/thinclient-agent/gallery-credentials
+MOUNTPOINT=/mnt/gallery
+PLAYLIST="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/idle-gallery-playlist.m3u"
+# Distinguishes this mpv instance from one a person (or thinclient-agent) launched
+# deliberately, so `stop` can pkill exactly it and nothing else — see mpv.conf's own
+# --input-ipc-server for why a marker in argv, not a socket, is the simpler match here.
+MARKER="--playlist=$PLAYLIST"
+DISPLAY_SECONDS=10
+
+blank() {
+  swaymsg "output * power off" >/dev/null 2>&1 || true
+}
+
+stop_slideshow() {
+  pkill -u "$(id -u)" -f "mpv .*${MARKER}" 2>/dev/null || true
+}
+
+if [ "${1:-start}" = "stop" ]; then
+  stop_slideshow
+  exit 0
+fi
+
+# Already running (e.g. a second timeout fired before the first resume) — nothing to do.
+if pgrep -u "$(id -u)" -f "mpv .*${MARKER}" >/dev/null 2>&1; then
+  exit 0
+fi
+
+if [ ! -r "$CREDENTIALS" ]; then
+  echo "idle-gallery: $CREDENTIALS not set up yet — see hosts/thin-client/README.md. Blanking instead." >&2
+  blank
+  exit 0
+fi
+
+# shellcheck disable=SC1090
+. "$CONFIG_ENV" 2>/dev/null || true
+if [ -z "${GALLERY_SMB_HOST:-}" ]; then
+  echo "idle-gallery: GALLERY_SMB_HOST is unset in $CONFIG_ENV. Blanking instead." >&2
+  blank
+  exit 0
+fi
+
+if ! mountpoint -q "$MOUNTPOINT" 2>/dev/null; then
+  sudo mkdir -p "$MOUNTPOINT"
+  # soft + a short connect timeout: a share that is merely unreachable right now (the
+  # container host is off, the network hiccuped) must fail in a few seconds, not hang
+  # the whole idle-transition — same "never block on a remote service" rule Phase
+  # 11.10 already applies to thinclient-agent's own MQTT connection.
+  if ! sudo mount -t cifs "//${GALLERY_SMB_HOST}/gallery" "$MOUNTPOINT" \
+      -o "credentials=${CREDENTIALS},ro,vers=3.0,soft,retry=0,echo_interval=5,uid=$(id -u),gid=$(id -g)" \
+      2>/tmp/idle-gallery-mount.err
+  then
+    echo "idle-gallery: mount failed — $(cat /tmp/idle-gallery-mount.err 2>/dev/null). Blanking instead." >&2
+    blank
+    exit 0
+  fi
+fi
+
+find "$MOUNTPOINT" -type f \( \
+    -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \
+  \) 2>/dev/null | shuf > "$PLAYLIST"
+
+if [ ! -s "$PLAYLIST" ]; then
+  echo "idle-gallery: $MOUNTPOINT has no images. Blanking instead." >&2
+  rm -f "$PLAYLIST"
+  blank
+  exit 0
+fi
+
+# Backgrounded, not exec'd: swayidle expects its timeout command to return, not to
+# become the slideshow for as long as it runs. --fs, not --fullscreen: sway's own
+# `for_window [app_id="mpv"] fullscreen enable` already applies, this just tells mpv
+# not to draw window decorations in the instant before that rule lands. app_id stays
+# the plain mpv one on purpose — fullscreen-watcher.sh's now-playing widget already
+# treats any fullscreen mpv window as "visual content on screen" and hides itself,
+# which is exactly the right behaviour here too.
+mpv --fs --no-osc --idle=no --image-display-duration="$DISPLAY_SECONDS" \
+  --loop-playlist=inf --shuffle "--playlist=$PLAYLIST" \
+  >/tmp/idle-gallery-mpv.log 2>&1 &
+disown
diff --git a/hosts/thin-client/configs/mpv/mpv.conf b/hosts/thin-client/configs/mpv/mpv.conf
new file mode 100644
index 0000000..ff906d7
--- /dev/null
+++ b/hosts/thin-client/configs/mpv/mpv.conf
@@ -0,0 +1,16 @@
+# mpv defaults for the kiosk user. Installed to
+# /home//.config/mpv/mpv.conf by build-thin-client-iso.sh.
+#
+# The only reason this file exists is the IPC socket: /usr/local/bin/fullscreen-watcher
+# queries mpv's `video-format` property over it to decide whether the now-playing
+# widget may be shown. Without the socket the watcher falls back to "is there an mpv
+# window in the sway tree", which is already correct for the normal case (mpv opens no
+# window for audio-only files) — this just makes --force-window playback behave too.
+#
+# VERIFY: `~/` expansion in an mpv.conf value is documented behaviour but has not been
+# checked on this image. If the socket never appears, replace the path below with the
+# absolute one for the kiosk user.
+input-ipc-server=~/.mpv-socket
+
+# Nothing here loads mpv-mpris: Debian's mpv-mpris package drops its .so into mpv's
+# autoload directory, and naming it again would load the plugin twice.
diff --git a/hosts/thin-client/configs/remote-desktop/rdp-vnc.json b/hosts/thin-client/configs/remote-desktop/rdp-vnc.json
new file mode 100644
index 0000000..571a07d
--- /dev/null
+++ b/hosts/thin-client/configs/remote-desktop/rdp-vnc.json
@@ -0,0 +1,24 @@
+{
+  "_comment": "TEMPLATE for the OUTBOUND remote-desktop targets — machines this thin client connects TO. Not to be confused with wayvnc, which is the inbound channel that lets other machines control this one. Baked into the image read-only at /etc/thinclient-agent/rdp-vnc.json; on first boot thinclient_agent/remote_desktop.py copies it to /var/lib/thinclient-agent/rdp-vnc.json and reads that copy from then on, so targets can be edited on a running machine without a rebuild.",
+
+  "_comment_no_credentials": "NO PASSWORDS IN THIS FILE, EVER — it is committed to git. 'credentials_ref' names a block in /etc/thinclient-agent/remote-desktop-credentials.env (mode 0600, gitignored, created by hand on the booted machine). See hosts/thin-client/README.md for the format. The agent reads only the username and domain out of that file; the password is never read, never written into a .remmina profile, and never put on a command line — Remmina prompts for it on first connect and stores it in its own keyring-backed store afterwards.",
+
+  "_comment_placeholders": "The two entries below are PLACEHOLDERS. The hosts are RFC-5737-style dummies, not real machines on any LAN. Replace them (or empty the list) before first boot; an unreachable target just makes Remmina show a connection error, but leaving fake names in the HA select is confusing.",
+
+  "targets": [
+    {
+      "name": "framework12",
+      "host": "192.168.1.0",
+      "port": 3389,
+      "protocol": "rdp",
+      "credentials_ref": "framework12"
+    },
+    {
+      "name": "thinkpad",
+      "host": "192.168.1.0",
+      "port": 5900,
+      "protocol": "vnc",
+      "credentials_ref": "thinkpad"
+    }
+  ]
+}
diff --git a/hosts/thin-client/configs/remote-desktop/remote-desktop-credentials.env.example b/hosts/thin-client/configs/remote-desktop/remote-desktop-credentials.env.example
new file mode 100644
index 0000000..b02ac37
--- /dev/null
+++ b/hosts/thin-client/configs/remote-desktop/remote-desktop-credentials.env.example
@@ -0,0 +1,22 @@
+# Example only. Copy to /etc/thinclient-agent/remote-desktop-credentials.env ON THE
+# BOOTED MACHINE, chmod 0600, chown to the kiosk user. Never commit the real file —
+# the repo's .gitignore drops *.env and keeps only *.env.example, which is why this one
+# is tracked and the real one is not.
+#
+# One block per `credentials_ref` in configs/remote-desktop/rdp-vnc.json. The ref is
+# uppercased and used as the variable prefix, so credentials_ref "framework12" reads
+# FRAMEWORK12_USERNAME / FRAMEWORK12_DOMAIN below.
+#
+# THERE IS NO PASSWORD VARIABLE, ON PURPOSE. thinclient_agent/remote_desktop.py reads
+# the username and domain only. A password here would have to be written into a
+# .remmina profile in cleartext (Remmina's own encryption is keyed by a secret in
+# remmina.pref, which this agent has no business reimplementing), and a cleartext
+# password for another machine on the LAN sitting in a file on an unattended kiosk is
+# a worse trade than one interactive prompt. Enter the password once in Remmina's
+# connect dialog and tick "save password"; Remmina keeps it from then on.
+
+FRAMEWORK12_USERNAME=your-username-here
+FRAMEWORK12_DOMAIN=
+
+THINKPAD_USERNAME=your-username-here
+THINKPAD_DOMAIN=
diff --git a/hosts/thin-client/configs/sway/config b/hosts/thin-client/configs/sway/config
new file mode 100644
index 0000000..9f44c43
--- /dev/null
+++ b/hosts/thin-client/configs/sway/config
@@ -0,0 +1,141 @@
+# Sway kiosk session for the thin client.
+# Installed to /home//.config/sway/config by build-thin-client-iso.sh.
+#
+# Every `exec` below inherits the environment set by /usr/local/bin/kiosk-session,
+# which sources /etc/thinclient-agent/config.env — that is how $DIGEST_WEB_URL gets
+# here without this file being templated.
+
+set $mod Mod4
+set $ws_web    1:web
+set $ws_digest 2:digest
+set $ws_media  3:media
+
+# Workspace names are a contract with thinclient_agent/digest_canvas.py and
+# thinclient_agent/sway_control.py — changing one side means changing the other.
+
+# ---------------------------------------------------------------------------
+# Output / input
+# ---------------------------------------------------------------------------
+output * bg #101014 solid_color
+
+input type:keyboard {
+    xkb_layout @KEYBOARD_LAYOUT@
+}
+
+input type:touchpad {
+    tap enabled
+    natural_scroll enabled
+}
+
+# ---------------------------------------------------------------------------
+# Look — no bars, no borders. Primary control is HA/MQTT and wayvnc; the local
+# display is a media surface, not a desktop.
+# ---------------------------------------------------------------------------
+default_border none
+default_floating_border none
+hide_edge_borders both
+gaps inner 0
+gaps outer 0
+
+# ---------------------------------------------------------------------------
+# Remote control
+# ---------------------------------------------------------------------------
+# exec_always so a `swaymsg reload` re-establishes it. start-wayvnc refuses to run
+# until /etc/wayvnc/wayvnc-password has been set on this machine.
+exec_always /usr/local/bin/start-wayvnc
+
+# Now-playing widget. Session-scoped (dies with sway, unlike thinclient-agent) — see
+# fullscreen-watcher.sh's own comment for why it isn't part of the system service.
+# Its own PID-file guard makes exec_always safe across `swaymsg reload`.
+exec_always /usr/local/bin/fullscreen-watcher
+
+# Camera gesture control. Session-scoped for the same blast-radius reason as the widget
+# above, plus a privacy one: the camera must not be open while there is no session whose
+# pointer it could move. It no-ops both when the image was built without it and when
+# gesture-config.json still has the default "enabled": false — see the wrapper.
+exec_always /usr/local/bin/gesture-control
+
+# thinclient-agent is NOT started here. systemd owns it (thinclient-agent.service,
+# enabled by 0700-thinclient-agent.hook.chroot) so that it is up and connected to
+# Mosquitto whether or not a graphical session ever comes up, and so it survives a
+# sway restart. Starting it from sway too would give two competing MQTT clients.
+
+# ---------------------------------------------------------------------------
+# Applications
+# ---------------------------------------------------------------------------
+# No `assign [app_id=...]` rules here on purpose: both digest-browser and web-browser
+# (added for the minimal-chrome general-browsing app) launch the same firefox-esr
+# app_id on different profiles, and `assign` cannot tell those two windows apart —
+# it would fight thinclient_agent.sway_control.launch_app()'s switch-workspace-then-
+# launch approach for whichever one it matched. Every launch path, including the one
+# below, is therefore explicit about its own workspace instead.
+# Steam Link runs under Xwayland (native Wayland black-screens on wlroots), so it
+# appears as an X11 class, not a Wayland app_id.
+assign [class="steamlink"]    $ws_media
+for_window [class="steamlink"] fullscreen enable
+for_window [app_id="mpv"] fullscreen enable
+
+# Guarded so a powered-off container host (or an unset DIGEST_WEB_URL) leaves an
+# empty workspace instead of hanging the session — Phase 11.10. The explicit
+# `swaymsg workspace` mirrors launch_app()'s own switch-then-launch order, since this
+# runs before thinclient-agent's MQTT-driven launches ever fire.
+exec sh -c '[ -n "$DIGEST_WEB_URL" ] && { swaymsg workspace $ws_digest; /usr/local/bin/digest-browser "$DIGEST_WEB_URL/full.html?detail_level=full"; }'
+
+# ---------------------------------------------------------------------------
+# Idle
+# ---------------------------------------------------------------------------
+# Never lock: this is an always-on shared media station, and a lock screen would make
+# the room's display unusable to anyone who isn't holding a keyboard. Any wayvnc or
+# local input resumes it.
+#
+# After 15 minutes, idle-gallery replaces the old unconditional "blank the panel"
+# behaviour with a photo slideshow from the gallery SMB share (Phase 11 follow-up) —
+# but it degrades all the way back to that exact blank-the-panel behaviour itself if
+# no share is configured, unreachable, or empty, so a freshly-built or offline thin
+# client behaves exactly as before. See configs/idle-gallery/idle-gallery.sh.
+exec swayidle -w \
+    timeout 900 '/usr/local/bin/idle-gallery' \
+    resume     'swaymsg "output * power on"; /usr/local/bin/idle-gallery stop'
+
+# Media playback keeps the screen alive.
+for_window [class="steamlink"] inhibit_idle focus
+for_window [app_id="mpv"] inhibit_idle focus
+for_window [app_id="firefox-esr"] inhibit_idle fullscreen
+
+# ---------------------------------------------------------------------------
+# Local override keys — a fallback for standing in front of the machine, not the
+# primary control surface.
+# ---------------------------------------------------------------------------
+
+# Maintenance shell. A deliberately obscure chord (not $mod+Return, which is the
+# ordinary local-terminal key above it) so it is not something a visitor bumps into,
+# floating so it overlays the kiosk content instead of tiling against it. It only opens
+# a shell — it does not pause thinclient-agent or anything else, since standing at the
+# machine already means treating it as a maintenance session; if that turns out to be
+# too little, tightening it is a config change here, not a new subsystem.
+bindsym $mod+Shift+Ctrl+m exec foot --title maintenance-shell
+for_window [title="maintenance-shell"] floating enable, resize set width 800 height 500, move position center
+
+bindsym $mod+Return exec foot
+bindsym $mod+q kill
+bindsym $mod+f fullscreen toggle
+bindsym $mod+Shift+c reload
+bindsym $mod+1 workspace $ws_web
+bindsym $mod+2 workspace $ws_digest
+bindsym $mod+3 workspace $ws_media
+bindsym $mod+Left focus left
+bindsym $mod+Right focus right
+bindsym $mod+Up focus up
+bindsym $mod+Down focus down
+
+bindsym XF86AudioPlay exec playerctl -p mpv,spotifyd play-pause
+bindsym XF86AudioNext exec playerctl -p mpv,spotifyd next
+bindsym XF86AudioPrev exec playerctl -p mpv,spotifyd previous
+bindsym XF86AudioRaiseVolume exec wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+
+bindsym XF86AudioLowerVolume exec wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
+bindsym XF86AudioMute exec wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
+
+# Deliberately no exit binding: `swaymsg exit` would drop to a black VT, and greetd
+# would just autologin straight back in. Use SSH or the local terminal to administer.
+
+workspace $ws_digest
diff --git a/hosts/thin-client/configs/sway/digest-browser b/hosts/thin-client/configs/sway/digest-browser
new file mode 100755
index 0000000..c00550a
--- /dev/null
+++ b/hosts/thin-client/configs/sway/digest-browser
@@ -0,0 +1,41 @@
+#!/bin/sh
+# Points the kiosk Firefox window at a URL. Installed to /usr/local/bin/digest-browser.
+# Called both from the sway config at session start and from thinclient-agent when a
+# "show digest" command arrives.
+#
+# Navigating by restart rather than by remote-command: Firefox's --kiosk window has no
+# tab bar or address bar, and a remote `firefox ` against a running kiosk instance
+# either opens an invisible tab or a second window that accumulates over time. Killing
+# and relaunching is blunt but deterministic, and the page is stateless anyway.
+#
+# Its own profile, separate from /usr/local/bin/web-browser's: a Firefox profile can
+# only be open in one process, so sharing one would mean opening the browsable window
+# closed the digest canvas. Both profiles carry the same user.js, chrome/userChrome.css
+# and enterprise policies.
+set -eu
+
+PROFILE_DIR="${HOME:-/home/$(id -un)}/.mozilla/firefox/digest"
+
+URL="${1:-${DIGEST_WEB_URL:-}}"
+[ -n "$URL" ] || { echo "digest-browser: no URL given and DIGEST_WEB_URL is unset" >&2; exit 1; }
+
+if command -v firefox-esr >/dev/null 2>&1; then
+  FIREFOX=firefox-esr
+else
+  FIREFOX=firefox
+fi
+
+mkdir -p "$PROFILE_DIR/chrome"
+cp /etc/thinclient-firefox/userChrome.css "$PROFILE_DIR/chrome/userChrome.css" 2>/dev/null || true
+cp /etc/thinclient-firefox/user.js        "$PROFILE_DIR/user.js"               2>/dev/null || true
+
+pkill -u "$(id -u)" -f "$FIREFOX .*--kiosk" 2>/dev/null || true
+
+# Wait for the old process to release its profile lock before relaunching.
+i=0
+while pgrep -u "$(id -u)" -f "$FIREFOX .*--kiosk" >/dev/null 2>&1 && [ "$i" -lt 20 ]; do
+  sleep 0.25
+  i=$((i + 1))
+done
+
+exec "$FIREFOX" --profile "$PROFILE_DIR" --new-instance --kiosk "$URL"
diff --git a/hosts/thin-client/configs/wayvnc/config b/hosts/thin-client/configs/wayvnc/config
new file mode 100644
index 0000000..0b36d95
--- /dev/null
+++ b/hosts/thin-client/configs/wayvnc/config
@@ -0,0 +1,26 @@
+# wayvnc — installed to /etc/wayvnc/config by build-thin-client-iso.sh.
+# Format is plain key=value, one per line (not TOML, not INI sections).
+#
+# There is deliberately NO `password=` line in this file. wayvnc only accepts the
+# password inline, so committing one here would put a live credential for a full
+# remote-control channel into git. Instead /usr/local/bin/start-wayvnc reads
+# /etc/wayvnc/wayvnc-password (mode 0600, never committed) and writes a merged config
+# into $XDG_RUNTIME_DIR at session start. If that file still holds the build-time
+# sentinel, start-wayvnc refuses to launch — no unauthenticated VNC server, ever.
+
+# Bound to all interfaces on purpose: wayvnc is this project's remote-control channel
+# (the confirmed replacement for RDP), so it has to be reachable from the LAN, not
+# just loopback. That is exactly why the auth below is not optional.
+address=0.0.0.0
+port=5900
+
+enable_auth=true
+username=@KIOSK_USERNAME@
+
+# wayvnc >= 0.7 uses this for RSA-AES auth.
+rsa_private_key_file=/etc/wayvnc/rsa_key.pem
+
+# wayvnc <= 0.6 authenticates over TLS instead and needs these two; harmless on newer
+# builds. Both are generated by 0300-wayvnc.hook.chroot, self-signed.
+private_key_file=/etc/wayvnc/tls_key.pem
+certificate_file=/etc/wayvnc/tls_cert.pem
diff --git a/hosts/thin-client/configs/wayvnc/start-wayvnc b/hosts/thin-client/configs/wayvnc/start-wayvnc
new file mode 100755
index 0000000..10428f6
--- /dev/null
+++ b/hosts/thin-client/configs/wayvnc/start-wayvnc
@@ -0,0 +1,36 @@
+#!/bin/sh
+# Launches wayvnc with a password that is never stored in the repo or in /etc/wayvnc/config.
+# Installed to /usr/local/bin/start-wayvnc, started from the sway config.
+set -eu
+
+BASE_CONFIG=/etc/wayvnc/config
+PASSWORD_FILE=/etc/wayvnc/wayvnc-password
+SENTINEL='CHANGEME-SET-ON-FIRST-BOOT'
+
+if [ ! -r "$PASSWORD_FILE" ]; then
+  echo "start-wayvnc: $PASSWORD_FILE is missing or unreadable — refusing to start." >&2
+  exit 1
+fi
+
+PASSWORD="$(head -n 1 "$PASSWORD_FILE" | tr -d '\r\n')"
+
+# Fail closed. An operator who forgets this step gets no remote access, rather than a
+# remote-control channel anyone on the LAN can open.
+if [ -z "$PASSWORD" ] || [ "$PASSWORD" = "$SENTINEL" ]; then
+  echo "start-wayvnc: no wayvnc password set. Run, as root, on this machine:" >&2
+  echo "  openssl rand -base64 24 > $PASSWORD_FILE && chmod 600 $PASSWORD_FILE" >&2
+  echo "  chown $(id -un):$(id -gn) $PASSWORD_FILE" >&2
+  echo "Then restart the session. Refusing to start an unauthenticated VNC server." >&2
+  exit 1
+fi
+
+RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/wayvnc"
+mkdir -p "$RUNTIME_DIR"
+chmod 700 "$RUNTIME_DIR"
+
+RUNTIME_CONFIG="$RUNTIME_DIR/config"
+umask 077
+cp "$BASE_CONFIG" "$RUNTIME_CONFIG"
+printf 'password=%s\n' "$PASSWORD" >> "$RUNTIME_CONFIG"
+
+exec wayvnc --config="$RUNTIME_CONFIG"
diff --git a/hosts/thin-client/live-build/config/hooks/normal/0100-user-setup.hook.chroot b/hosts/thin-client/live-build/config/hooks/normal/0100-user-setup.hook.chroot
new file mode 100755
index 0000000..bb00e26
--- /dev/null
+++ b/hosts/thin-client/live-build/config/hooks/normal/0100-user-setup.hook.chroot
@@ -0,0 +1,53 @@
+#!/bin/sh
+# Creates the kiosk account the whole image is built around.
+#
+# live-build runs chroot_local-includes BEFORE chroot_local-hooks, so
+# /etc/thinclient-agent/config.env (written by build-thin-client-iso.sh) already
+# exists here. Sourcing it is why hooks don't need placeholder/sed templating.
+set -eu
+
+. /etc/thinclient-agent/config.env
+
+if ! id "$KIOSK_USERNAME" >/dev/null 2>&1; then
+  useradd --create-home --shell /bin/bash --comment "Thin client kiosk session" "$KIOSK_USERNAME"
+fi
+
+for grp in audio video input render dialout netdev plugdev seat _seatd; do
+  if getent group "$grp" >/dev/null 2>&1; then
+    adduser "$KIOSK_USERNAME" "$grp" >/dev/null
+  fi
+done
+
+# No password is baked in: the account is locked so it can never be used to log in
+# remotely, while the physical console still autologins via greetd.
+passwd --lock "$KIOSK_USERNAME" >/dev/null
+
+adduser "$KIOSK_USERNAME" sudo >/dev/null
+
+# Passwordless sudo is a deliberate call, not laziness: this image autologins to an
+# unattended interactive Sway session at the physical console, so anyone standing in
+# front of the machine already has the equivalent of a root shell. Requiring a
+# password here would buy nothing while making the locked account unadministrable.
+# The boundaries that actually matter are the wayvnc password and key-only SSH below.
+cat > "/etc/sudoers.d/010-${KIOSK_USERNAME}" < /etc/ssh/sshd_config.d/10-thin-client.conf <<'EOF'
+PermitRootLogin no
+PasswordAuthentication no
+KbdInteractiveAuthentication no
+PubkeyAuthentication yes
+EOF
+
+if [ -d "/home/${KIOSK_USERNAME}/.ssh" ]; then
+  chmod 700 "/home/${KIOSK_USERNAME}/.ssh"
+  [ -f "/home/${KIOSK_USERNAME}/.ssh/authorized_keys" ] && \
+    chmod 600 "/home/${KIOSK_USERNAME}/.ssh/authorized_keys"
+fi
+
+chown -R "${KIOSK_USERNAME}:${KIOSK_USERNAME}" "/home/${KIOSK_USERNAME}"
+
+systemctl enable ssh >/dev/null 2>&1 || true
diff --git a/hosts/thin-client/live-build/config/hooks/normal/0200-greetd.hook.chroot b/hosts/thin-client/live-build/config/hooks/normal/0200-greetd.hook.chroot
new file mode 100755
index 0000000..657f329
--- /dev/null
+++ b/hosts/thin-client/live-build/config/hooks/normal/0200-greetd.hook.chroot
@@ -0,0 +1,24 @@
+#!/bin/sh
+# Makes greetd the boot target so the machine comes up straight in the kiosk Sway
+# session (config in /etc/greetd/config.toml, shipped via includes.chroot).
+set -eu
+
+. /etc/thinclient-agent/config.env
+
+chmod 0755 /usr/local/bin/kiosk-session
+
+# greetd's own package user; it still needs to exist even though no greeter UI runs.
+if ! id greeter >/dev/null 2>&1; then
+  useradd --system --create-home --home-dir /var/lib/greetd --shell /usr/sbin/nologin greeter
+fi
+
+systemctl enable greetd
+systemctl set-default graphical.target
+
+# live-config would otherwise autologin its own account on tty1 and fight greetd for
+# the VT. build-thin-client-iso.sh passes `noautologin` on the kernel command line;
+# masking the getty on vt1 makes that robust even if someone edits the boot args.
+systemctl mask getty@tty1.service
+
+mkdir -p "/home/${KIOSK_USERNAME}/.config/sway"
+chown -R "${KIOSK_USERNAME}:${KIOSK_USERNAME}" "/home/${KIOSK_USERNAME}/.config"
diff --git a/hosts/thin-client/live-build/config/hooks/normal/0300-wayvnc.hook.chroot b/hosts/thin-client/live-build/config/hooks/normal/0300-wayvnc.hook.chroot
new file mode 100755
index 0000000..df7b412
--- /dev/null
+++ b/hosts/thin-client/live-build/config/hooks/normal/0300-wayvnc.hook.chroot
@@ -0,0 +1,35 @@
+#!/bin/sh
+# Prepares wayvnc's auth material. Deliberately does NOT set a password.
+set -eu
+
+. /etc/thinclient-agent/config.env
+
+mkdir -p /etc/wayvnc
+chmod 0755 /usr/local/bin/start-wayvnc
+
+# wayvnc's RSA-AES auth needs a key pair; it is machine-local and carries no secret
+# that belongs in git, so generating it at build time is fine.
+if [ ! -f /etc/wayvnc/rsa_key.pem ]; then
+  openssl genrsa -out /etc/wayvnc/rsa_key.pem 2048 2>/dev/null
+fi
+
+# Self-signed TLS material, needed only by wayvnc <= 0.6 whose auth path is TLS-based
+# rather than RSA-AES. Harmless on newer versions.
+if [ ! -f /etc/wayvnc/tls_key.pem ]; then
+  openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
+    -keyout /etc/wayvnc/tls_key.pem -out /etc/wayvnc/tls_cert.pem \
+    -subj "/CN=thin-client" 2>/dev/null
+fi
+
+# Sentinel, not a password. start-wayvnc refuses to launch while this value is still
+# here, so the failure mode of "operator forgot to set a password" is "no VNC server"
+# rather than "an unauthenticated VNC server on the LAN". A real value must never be
+# committed — see hosts/thin-client/README.md.
+if [ ! -f /etc/wayvnc/wayvnc-password ]; then
+  printf 'CHANGEME-SET-ON-FIRST-BOOT\n' > /etc/wayvnc/wayvnc-password
+fi
+
+chmod 0600 /etc/wayvnc/wayvnc-password /etc/wayvnc/rsa_key.pem /etc/wayvnc/tls_key.pem
+chown "${KIOSK_USERNAME}:${KIOSK_USERNAME}" \
+  /etc/wayvnc/wayvnc-password /etc/wayvnc/rsa_key.pem /etc/wayvnc/tls_key.pem
+chmod 0644 /etc/wayvnc/tls_cert.pem /etc/wayvnc/config
diff --git a/hosts/thin-client/live-build/config/hooks/normal/0400-flatpak-steamlink.hook.chroot b/hosts/thin-client/live-build/config/hooks/normal/0400-flatpak-steamlink.hook.chroot
new file mode 100755
index 0000000..e4115d4
--- /dev/null
+++ b/hosts/thin-client/live-build/config/hooks/normal/0400-flatpak-steamlink.hook.chroot
@@ -0,0 +1,25 @@
+#!/bin/sh
+# Flathub remote + Steam Link.
+set -eu
+
+. /etc/thinclient-agent/config.env
+
+flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
+
+if [ "${ENABLE_STEAM_LINK}" != "true" ]; then
+  echo "0400-flatpak-steamlink: ENABLE_STEAM_LINK is false, skipping Steam Link install."
+  exit 0
+fi
+
+# VERIFY BEFORE THE FIRST REAL BUILD: confirm this application ID against the live
+# Flathub listing (`flatpak search "Steam Link"`). It is believed correct but has not
+# been checked against Flathub from this environment.
+STEAM_LINK_APP_ID="com.valvesoftware.SteamLink"
+
+if flatpak install -y --noninteractive flathub "$STEAM_LINK_APP_ID"; then
+  echo "0400-flatpak-steamlink: installed ${STEAM_LINK_APP_ID}."
+else
+  echo "0400-flatpak-steamlink: WARNING — could not install ${STEAM_LINK_APP_ID} during the"
+  echo "  build (no network in the chroot, or the app ID is wrong). Run this on the booted"
+  echo "  image instead:  flatpak install -y flathub ${STEAM_LINK_APP_ID}"
+fi
diff --git a/hosts/thin-client/live-build/config/hooks/normal/0500-spotify-connect.hook.chroot b/hosts/thin-client/live-build/config/hooks/normal/0500-spotify-connect.hook.chroot
new file mode 100755
index 0000000..c7651da
--- /dev/null
+++ b/hosts/thin-client/live-build/config/hooks/normal/0500-spotify-connect.hook.chroot
@@ -0,0 +1,39 @@
+#!/bin/sh
+# Headless Spotify Connect receiver (project-plan Phase 11.6).
+#
+# Neither spotifyd nor librespot is in Debian bookworm main, so this hook tries apt
+# first (in case a backport/third-party repo has been added) and otherwise stops at a
+# documented placeholder. No release URL is hardcoded on purpose: pinning a version
+# here would silently 404 the moment upstream moves a tag, which is worse than an
+# explicit "do this by hand" message.
+set -eu
+
+INSTALLED=""
+
+for pkg in spotifyd librespot; do
+  if apt-get install -y --no-install-recommends "$pkg" 2>/dev/null; then
+    INSTALLED="$pkg"
+    break
+  fi
+done
+
+if [ -z "$INSTALLED" ]; then
+  echo "0500-spotify-connect: neither spotifyd nor librespot is available from the"
+  echo "  configured apt sources. Spotify Connect will NOT work on this image."
+  echo "  To add it, pick one and re-run the build with the step filled in here:"
+  echo "    a) fetch a release binary from https://github.com/Spotifyd/spotifyd/releases"
+  echo "       (check the current tag yourself) into /usr/local/bin/spotifyd, or"
+  echo "    b) cargo-build librespot in a build hook, or"
+  echo "    c) add a third-party apt repo that carries it."
+  echo "  Then drop a spotifyd.service unit in and enable it. Spotify Premium is required."
+  exit 0
+fi
+
+echo "0500-spotify-connect: installed ${INSTALLED} from apt."
+
+# Runs as the kiosk user so its MPRIS interface lands on the same session bus that
+# playerctl (and therefore thinclient-agent's media_player entity) reads.
+if [ "$INSTALLED" = "spotifyd" ]; then
+  systemctl enable spotifyd 2>/dev/null || \
+    echo "0500-spotify-connect: no packaged spotifyd unit; enable it by hand after configuring /etc/spotifyd.conf."
+fi
diff --git a/hosts/thin-client/live-build/config/hooks/normal/0600-voice-satellite.hook.chroot b/hosts/thin-client/live-build/config/hooks/normal/0600-voice-satellite.hook.chroot
new file mode 100755
index 0000000..69f1f27
--- /dev/null
+++ b/hosts/thin-client/live-build/config/hooks/normal/0600-voice-satellite.hook.chroot
@@ -0,0 +1,76 @@
+#!/bin/sh
+# wyoming-satellite + openWakeWord, for the mic-enabled rooms only.
+set -eu
+
+. /etc/thinclient-agent/config.env
+
+if [ "${ENABLE_VOICE_SATELLITE}" != "true" ]; then
+  echo "0600-voice-satellite: ENABLE_VOICE_SATELLITE is false, skipping (this is the"
+  echo "  default — only images destined for the specific rooms that have a microphone"
+  echo "  should enable it)."
+  exit 0
+fi
+
+VENV=/opt/voice-satellite/venv
+
+# A venv rather than `pip3 install --break-system-packages`: bookworm's system
+# interpreter is externally managed (PEP 668), and these packages pull a large,
+# fast-moving dependency tree (onnxruntime, numpy) that would otherwise be free to
+# overwrite apt-managed versions the rest of the image depends on.
+python3 -m venv "$VENV"
+"$VENV/bin/pip" install --upgrade pip wheel setuptools
+
+# VERIFY BEFORE THE FIRST REAL BUILD: upstream's documented install path is a git
+# clone + script/setup rather than PyPI. If either of these names is not on PyPI,
+# swap this for:
+#   git clone https://github.com/rhasspy/wyoming-satellite /opt/voice-satellite/src
+#   /opt/voice-satellite/src/script/setup
+if ! "$VENV/bin/pip" install wyoming-satellite wyoming-openwakeword; then
+  echo "0600-voice-satellite: WARNING — pip install failed. Fall back to the upstream"
+  echo "  git-clone install described in the comment above this line."
+  exit 0
+fi
+
+cat > /etc/systemd/system/wyoming-openwakeword.service < /etc/systemd/system/wyoming-satellite.service </dev/null; then
+  echo "0800-eww-widget: installed eww from apt."
+else
+  echo "0800-eww-widget: eww is not available from the configured apt sources."
+  echo "  The now-playing widget will not appear on this image until eww is installed:"
+  echo "    a) cargo install eww (needs a Rust toolchain in a build hook), or"
+  echo "    b) fetch a release binary from https://github.com/elkowar/eww/releases"
+  echo "       (check the current tag yourself) into /usr/local/bin/eww."
+  echo "  configs/eww/fullscreen-watcher.sh already detects a missing eww and no-ops,"
+  echo "  so the rest of the session is unaffected either way."
+fi
diff --git a/hosts/thin-client/live-build/config/hooks/normal/0900-firefox.hook.chroot b/hosts/thin-client/live-build/config/hooks/normal/0900-firefox.hook.chroot
new file mode 100644
index 0000000..2460d2c
--- /dev/null
+++ b/hosts/thin-client/live-build/config/hooks/normal/0900-firefox.hook.chroot
@@ -0,0 +1,22 @@
+#!/bin/sh
+# Firefox enterprise policy + kiosk-chrome plumbing (project-plan Phase 11, follow-up:
+# minimal browser chrome + preinstalled uBlock Origin/SponsorBlock).
+#
+# policies.json is installed to /etc/firefox/policies/policies.json by includes.chroot
+# (the documented Linux location), and linked here into firefox-esr's own
+# distribution/ directory too — an older location some builds read instead of the
+# system one. Whichever the installed firefox-esr honours wins; the other is inert.
+set -eu
+
+FIREFOX_LIB_DIR="/usr/lib/firefox-esr"
+
+if [ -d "$FIREFOX_LIB_DIR" ]; then
+  mkdir -p "$FIREFOX_LIB_DIR/distribution"
+  ln -sf /etc/firefox/policies/policies.json "$FIREFOX_LIB_DIR/distribution/policies.json"
+  echo "0900-firefox: linked policies.json into $FIREFOX_LIB_DIR/distribution/"
+else
+  echo "0900-firefox: $FIREFOX_LIB_DIR not found (firefox-esr not installed yet, or a"
+  echo "  different path in this Debian release) — /etc/firefox/policies/policies.json"
+  echo "  still applies if the package's install order runs after this hook; verify"
+  echo "  about:policies shows uBlock Origin/SponsorBlock as force-installed on first boot."
+fi
diff --git a/hosts/thin-client/live-build/config/hooks/normal/1000-ydotool.hook.chroot b/hosts/thin-client/live-build/config/hooks/normal/1000-ydotool.hook.chroot
new file mode 100644
index 0000000..0f651ae
--- /dev/null
+++ b/hosts/thin-client/live-build/config/hooks/normal/1000-ydotool.hook.chroot
@@ -0,0 +1,28 @@
+#!/bin/sh
+# Input injection for the HA mobile-app browser remote (project-plan Phase 11,
+# follow-up: text field + mouse buttons).
+#
+# ydotool writes to /dev/uinput as a virtual input device rather than talking to a
+# display-server protocol (see thinclient_agent/input_control.py) — that device node is
+# root-owned by default, which is what ydotoold (present in ydotool >= 1.0) exists to
+# broker: it runs as a system service and the client talks to it over a socket instead
+# of opening /dev/uinput itself. Debian bookworm's ydotool version, and therefore
+# whether ydotoold even exists, decides which of the two paths below applies —
+# InputControl already detects this the same way (shutil.which("ydotoold")).
+set -eu
+
+. /etc/thinclient-agent/config.env
+
+apt-get install -y --no-install-recommends ydotool
+
+if systemctl list-unit-files ydotoold.service >/dev/null 2>&1; then
+  systemctl enable ydotoold
+  echo "1000-ydotool: enabled ydotoold.service (modern ydotool)."
+else
+  echo "1000-ydotool: no ydotoold.service shipped with this package (legacy ydotool that"
+  echo "  opens /dev/uinput directly). It needs to run as root or have a udev rule"
+  echo "  granting the '${KIOSK_USERNAME}' user access to /dev/uinput — 0100-user-setup"
+  echo "  already put that account in the 'input' group, which covers the common udev"
+  echo "  rule pattern (GROUP=\"input\") if this Debian release ships one for uinput;"
+  echo "  add one by hand if the input-control buttons in Home Assistant do nothing."
+fi
diff --git a/hosts/thin-client/live-build/config/hooks/normal/1100-gesture-control.hook.chroot b/hosts/thin-client/live-build/config/hooks/normal/1100-gesture-control.hook.chroot
new file mode 100755
index 0000000..b817caa
--- /dev/null
+++ b/hosts/thin-client/live-build/config/hooks/normal/1100-gesture-control.hook.chroot
@@ -0,0 +1,77 @@
+#!/bin/sh
+# Camera gesture control (open hand moves the pointer, fist clicks) — OPT-IN, and for
+# the specific rooms that are getting a webcam only.
+#
+# Two independent gates, on purpose. This one is per-image and decides whether the
+# software is on the disk at all: MediaPipe plus its OpenCV/numpy/matplotlib tail is a
+# few hundred MB, which has no business being in the image of a room that has no camera.
+# The second gate is the "enabled" flag in gesture-config.json and decides whether the
+# camera is ever OPENED — it defaults to false even on an image built with this hook, so
+# flashing a gesture-capable image is not the same act as switching a room's camera on.
+#
+# A venv rather than apt or `pip3 --break-system-packages`, same reasoning as
+# 0600-voice-satellite.hook.chroot: bookworm's system interpreter is PEP 668
+# externally-managed, and mediapipe drags in its own opencv-contrib-python and numpy
+# which must not overwrite the apt-managed versions the rest of the image uses.
+#
+# No apt attempt first, unlike 0500/0800: mediapipe has never been packaged in Debian
+# (there is no python3-mediapipe in bookworm or trixie) and upstream ships only PyPI
+# wheels, so an `apt-get install mediapipe` here would be theatre rather than a real
+# fallback. The other half of that convention — fail to a documented placeholder rather
+# than to a broken image — does apply, and is what every failure path below does.
+set -eu
+
+. /etc/thinclient-agent/config.env
+
+if [ "${ENABLE_GESTURE_CONTROL}" != "true" ]; then
+  echo "1100-gesture-control: ENABLE_GESTURE_CONTROL is false, skipping (this is the"
+  echo "  default — only images destined for a room that is getting a webcam should"
+  echo "  enable it, exactly as with ENABLE_VOICE_SATELLITE and microphones)."
+  exit 0
+fi
+
+VENV=/opt/gesture-control/venv
+MODEL=/opt/gesture-control/hand_landmarker.task
+
+# The published MediaPipe Hand Landmarker bundle. This IS a hardcoded URL, which the
+# rest of this tree avoids — the justification is that the trailing path is Google's own
+# `/latest/` alias rather than a version tag, so it does not rot the way a pinned GitHub
+# release tag does, and since mediapipe 1.0.0 dropped the legacy mediapipe.solutions
+# package the model no longer ships inside the wheel. VERIFY it still resolves if the
+# download below starts failing:
+#   https://ai.google.dev/edge/mediapipe/solutions/vision/hand_landmarker#models
+MODEL_URL="https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/latest/hand_landmarker.task"
+
+python3 -m venv "$VENV"
+"$VENV/bin/pip" install --upgrade pip wheel setuptools
+
+# mediapipe pulls opencv-contrib-python itself, so there is nothing to install for the
+# capture side. Debian's own python3-opencv is deliberately NOT used: it does not satisfy
+# mediapipe's opencv-contrib-python requirement, so listing it would only put a second
+# copy of OpenCV on the image. The apt packages this needs are the shared libraries those
+# wheels dlopen at runtime, and they are in thin-client.list.chroot.
+if ! "$VENV/bin/pip" install mediapipe; then
+  echo "1100-gesture-control: WARNING — 'pip install mediapipe' failed. Gesture control"
+  echo "  will not work on this image; nothing else is affected (the sway wrapper detects"
+  echo "  the missing venv and no-ops)."
+  echo "  Most likely causes: no network during the build, or upstream stopped publishing"
+  echo "  a manylinux wheel for this interpreter. bookworm is Python 3.11 and glibc 2.36;"
+  echo "  mediapipe 1.0.0 ships py3-none-manylinux_2_28_x86_64, which fits. Check"
+  echo "  https://pypi.org/project/mediapipe/#files if that changes."
+  exit 0
+fi
+
+if ! curl -fsSL -o "$MODEL" "$MODEL_URL"; then
+  echo "1100-gesture-control: WARNING — could not download the hand-landmarker model."
+  echo "  Fetch it by hand onto the booted machine and gesture control starts working:"
+  echo "    sudo curl -fsSL -o ${MODEL} \\"
+  echo "      '${MODEL_URL}'"
+  echo "  gesture_pointer.py exits cleanly while the file is missing."
+  exit 0
+fi
+
+chown -R root:root /opt/gesture-control
+chmod 0644 "$MODEL"
+
+echo "1100-gesture-control: installed. The camera stays OFF until \"enabled\" is set to"
+echo "  true in /var/lib/thinclient-agent/gesture-config.json on the booted machine."
diff --git a/hosts/thin-client/live-build/config/package-lists/thin-client.list.chroot b/hosts/thin-client/live-build/config/package-lists/thin-client.list.chroot
new file mode 100644
index 0000000..c3056f7
--- /dev/null
+++ b/hosts/thin-client/live-build/config/package-lists/thin-client.list.chroot
@@ -0,0 +1,113 @@
+# Thin-client package list (live-build .list.chroot format: one package per line).
+# Phase 11, docs/project-plan.md.
+
+# --- Wayland compositor / session ---
+sway
+swaylock
+swayidle
+swaybg
+xwayland
+greetd
+foot
+
+# --- Remote view/control (the chosen replacement for RDP — see project-plan §2) ---
+wayvnc
+
+# --- Browser / media ---
+firefox-esr
+mpv
+# mpv-mpris is packaged in Debian bookworm (mpv-mpris 0.9-x). VERIFY with
+# `apt-cache policy mpv-mpris` against your mirror before the first real build; if it
+# is missing, it has to be built from source in a hook and needs mpv + libmpv-dev.
+mpv-mpris
+libmpv2
+playerctl
+
+# --- Audio ---
+pipewire
+pipewire-pulse
+wireplumber
+alsa-utils
+
+# --- Steam Link comes from Flathub, not apt (see 0400-flatpak-steamlink.hook.chroot) ---
+flatpak
+
+# --- Agent runtime ---
+python3
+python3-venv
+# Debian bookworm ships paho-mqtt 1.6.x. thinclient_agent is written to work against
+# both the 1.x and 2.x callback APIs, so this package is used as-is rather than pip'd.
+python3-paho-mqtt
+procps
+
+# --- Networking ---
+# NetworkManager over iwd+systemd-networkd: these boxes are wired-preferred but some
+# rooms will be WiFi-only, and NM handles both from one tool (nmcli/nmtui) that a human
+# can drive over wayvnc or SSH without editing per-interface unit files.
+network-manager
+
+# --- Out-of-band admin (wayvnc is the primary remote channel; SSH is the fallback
+#     for when the compositor itself is what's broken) ---
+openssh-server
+sudo
+
+# --- Now-playing widget (0800-eww-widget.hook.chroot tries apt, falls back to a
+#     documented placeholder if not packaged; foot is already listed above for the
+#     maintenance-shell keybind, fullscreen-watcher.sh's tree query just needs jq) ---
+eww
+jq
+
+# --- Outbound RDP/VNC client (thinclient_agent/remote_desktop.py) — one app for both
+#     protocols instead of xfreerdp + a separate VNC viewer ---
+remmina
+remmina-plugin-rdp
+remmina-plugin-vnc
+
+# --- HA mobile-app browser remote (thinclient_agent/input_control.py) — Wayland input
+#     injection; NOT xdotool, which is X11-only ---
+ydotool
+
+# --- Camera gesture control (1100-gesture-control.hook.chroot, OPT-IN per image) ---
+# Deliberately NOT python3-opencv: mediapipe declares opencv-contrib-python as a
+# dependency and pip installs its own copy into the venv regardless, so apt's OpenCV
+# would just be a second unused one. What IS needed from apt is the set of shared
+# libraries those manylinux wheels dlopen at runtime — the wheels bundle none of these.
+libgl1
+libglib2.0-0
+# sounddevice is a hard dependency of mediapipe and loads PortAudio at import time even
+# though only its audio tasks use it.
+libportaudio2
+# For finding the right /dev/video* to put in gesture-config.json: `v4l2-ctl --list-devices`.
+v4l-utils
+
+# --- Idle-timeout gallery slideshow (configs/idle-gallery/) — mount.cifs for the
+#     read-only SMB share; the slideshow player itself is mpv, already listed above ---
+cifs-utils
+
+# --- Firmware for real hardware (needs contrib + non-free-firmware archive areas,
+#     which build-thin-client-iso.sh passes to `lb config`) ---
+firmware-linux
+firmware-iwlwifi
+firmware-realtek
+firmware-misc-nonfree
+
+# --- Build/runtime deps for the pip-installed voice satellite venv ---
+python3-dev
+build-essential
+libopenblas0
+git
+
+# --- Misc ---
+ca-certificates
+curl
+openssl
+less
+vim-tiny
+
+# NOTE: wyoming-satellite and openwakeword are Python packages, NOT Debian packages.
+# They are installed into a venv under /opt by 0600-voice-satellite.hook.chroot
+# (PEP 668 makes a bare `pip3 install` into the system interpreter fail on bookworm).
+# NOTE: spotifyd/librespot are not in bookworm main either — see
+# 0500-spotify-connect.hook.chroot.
+# NOTE: mediapipe is not a Debian package at all (no python3-mediapipe in bookworm or
+# trixie); it is PyPI-only and goes into its own venv, see 1100-gesture-control.hook.chroot.
diff --git a/hosts/thin-client/live-build/config/preseed.cfg b/hosts/thin-client/live-build/config/preseed.cfg
new file mode 100644
index 0000000..4fab263
--- /dev/null
+++ b/hosts/thin-client/live-build/config/preseed.cfg
@@ -0,0 +1,13 @@
+# Only relevant when ENABLE_INSTALLER="true" in build-thin-client-iso.sh (off by
+# default — the normal path is live-boot straight into Sway, no disk install at all).
+#
+# live-build auto-includes config/preseed.cfg into the debian-installer's preseed when
+# --debian-installer is not "none". VERIFY: this is live-build's documented mechanism
+# for this, but has not been exercised — if ENABLE_INSTALLER is ever turned on, confirm
+# the installer's keyboard step actually shows "German" pre-selected rather than
+# falling back to its own default.
+#
+# No "keyboard-configuration/xkb-keymap seen true" line on purpose: this preseeds the
+# *default* answer, it does not skip the question, so whoever runs the installer can
+# still pick a different layout for this specific machine.
+d-i keyboard-configuration/xkb-keymap select de
diff --git a/hosts/thin-client/scripts/build-thin-client-iso.sh b/hosts/thin-client/scripts/build-thin-client-iso.sh
new file mode 100755
index 0000000..b28b6a3
--- /dev/null
+++ b/hosts/thin-client/scripts/build-thin-client-iso.sh
@@ -0,0 +1,443 @@
+#!/usr/bin/env bash
+#
+# Smart Home Thin-Client ISO Builder
+# Target: builds a Debian 12 (Bookworm) live ISO on a Debian/Ubuntu build machine
+#
+# Drives `lb config && lb build` over hosts/thin-client/live-build/ to produce the
+# Sway kiosk media-station image described in docs/project-plan.md Phase 11:
+#   - greetd autologin straight into a kiosk Sway session (no greeter UI)
+#   - wayvnc for interactive remote view/control (the confirmed replacement for RDP)
+#   - thinclient-agent (Python, systemd) — HA MQTT-discovery entities + swaymsg control
+#   - Firefox kiosk workspace pointed at digest-web (Phase 12)
+#   - mpv + mpv-mpris, playerctl, PipeWire audio
+#   - Steam Link via Flathub, under Xwayland
+#   - wyoming-satellite + openWakeWord (OPT-IN, mic-enabled rooms only)
+#
+# This script is also the single point that keeps configs/ (the human-edited source of
+# truth, reviewed in git) in sync with live-build/config/includes.chroot/ (the
+# generated tree that actually gets baked into the image). Never hand-edit anything
+# under includes.chroot — it is wiped and regenerated on every run.
+#
+# Run as: sudo ./build-thin-client-iso.sh
+#
+# EDIT THE VARIABLES BELOW BEFORE RUNNING.
+
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# CONFIGURATION — edit these before running
+# ---------------------------------------------------------------------------
+DEBIAN_RELEASE="bookworm"           # Matches the container host's OS
+KIOSK_USERNAME="kiosk"              # The autologin account the whole image is built around
+IMAGE_HOSTNAME="thin-client"        # Hostname baked into the image
+THINCLIENT_NAME="Living room thin client"   # Friendly name shown on the HA device
+
+# Console + Sway keyboard layout. This IS the "installer choice" in this image's
+# architecture: there is no interactive keymap prompt in the normal live-boot path (see
+# ENABLE_INSTALLER below for the one case where a real prompt exists), so a per-image
+# build variable is what stands in for it — build one ISO per keyboard layout you need.
+KEYBOARD_LAYOUT="de"                # xkb layout name (`localectl list-x11-keymap-layouts`)
+
+ENABLE_STEAM_LINK="true"            # Install the Steam Link flatpak from Flathub
+ENABLE_INSTALLER="false"            # "true" adds a debian-installer to the ISO (install to disk)
+
+# --- Voice satellite — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY -----------
+# Per docs/project-plan.md Phase 11.8, only the specific rooms that have a microphone
+# run wyoming-satellite. This is therefore a PER-IMAGE decision, not a universal one:
+# build one ISO with this "false" for the silent rooms, and a second ISO with it "true"
+# for the mic-enabled ones. The exact mic-enabled room list is still an open decision
+# (project-plan §4 #6) — do not flip this on until it has been chosen.
+ENABLE_VOICE_SATELLITE="false"
+VOICE_SATELLITE_NAME="Living room"  # Shown in HA's Wyoming/Assist device list
+VOICE_WAKE_WORD="ok_nabu"           # openWakeWord model name
+
+# --- Camera gesture control — OFF BY DEFAULT, AND MEANT TO STAY THAT WAY -------
+# Open hand moves the pointer, fist clicks. Same per-image, per-room logic as the mic
+# above: only build this into the image of a room that is actually getting a webcam.
+# This flag only decides whether MediaPipe and its ~400 MB dependency tree are INSTALLED.
+# Whether the camera is ever OPENED is a second, separate gate — the "enabled" flag in
+# configs/gesture-control/gesture-config.json, which is false by default even here, so a
+# gesture-capable image still ships with the camera off. See the privacy section in
+# hosts/thin-client/README.md.
+ENABLE_GESTURE_CONTROL="false"
+
+# --- Where the thin client talks to ----------------------------------------
+# The container host from Phase 1 (Mosquitto + Home Assistant). Fill in its LAN IP.
+MQTT_BROKER_HOST="192.168.1.10"     # <-- EDIT: container-host IP running Mosquitto
+MQTT_BROKER_PORT="1883"
+MQTT_USERNAME=""                    # Leave empty while Mosquitto runs allow_anonymous
+MQTT_PASSWORD=""                    # Never commit a real value here — see README
+HA_URL="http://192.168.1.10:8123"   # <-- EDIT: Home Assistant URL
+
+# digest-web is the static-file service that Phase 12's digest-engine renders into.
+# It is built by a separate workstream; until it is deployed this is just a placeholder
+# and the kiosk Firefox workspace will show a connection error (harmless — the session
+# must still come up with the container host powered off, per Phase 11.10).
+DIGEST_WEB_URL="http://192.168.1.10:8081"   # <-- EDIT once digest-web is deployed
+
+# The container host's gallery-smb share (ENABLE_GALLERY_SMB in
+# setup-container-host.sh), used by the idle-timeout slideshow. Just the host —
+# idle-gallery.sh always mounts the fixed "gallery" share name.
+GALLERY_SMB_HOST="192.168.1.10"     # <-- EDIT: container-host IP running gallery-smb
+
+# Optional: an SSH public key to bake into the kiosk account for out-of-band admin.
+# The image ships with password auth disabled, so without this the only admin path is
+# the local console or wayvnc.
+SSH_AUTHORIZED_KEY=""
+
+# ---------------------------------------------------------------------------
+# Paths
+# ---------------------------------------------------------------------------
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+THIN_CLIENT_DIR="$(dirname "$SCRIPT_DIR")"
+CONFIGS_DIR="${THIN_CLIENT_DIR}/configs"
+AGENT_DIR="${THIN_CLIENT_DIR}/agent"
+LIVE_BUILD_DIR="${THIN_CLIENT_DIR}/live-build"
+INCLUDES="${LIVE_BUILD_DIR}/config/includes.chroot"
+PACKAGE_LIST="${LIVE_BUILD_DIR}/config/package-lists/thin-client.list.chroot"
+
+# ---------------------------------------------------------------------------
+# Sanity checks
+# ---------------------------------------------------------------------------
+if [[ $EUID -ne 0 ]]; then
+  echo "Warning: not running as root. 'lb build' needs root to bootstrap and chroot,"
+  echo "  and will fail partway through. Re-run with: sudo $0"
+  echo "  Continuing anyway so you can at least regenerate includes.chroot..."
+fi
+
+if ! grep -qi "debian\|ubuntu" /etc/os-release; then
+  echo "Warning: live-build targets a Debian/Ubuntu build host. Proceeding anyway..."
+fi
+
+if ! command -v lb &> /dev/null; then
+  if [[ $EUID -eq 0 ]]; then
+    echo "--- Installing live-build ---"
+    apt-get update
+    apt-get install -y live-build
+  else
+    echo "live-build is not installed and this script is not running as root." >&2
+    echo "  Install it first: sudo apt-get install live-build" >&2
+    exit 1
+  fi
+else
+  echo "--- live-build already installed, skipping ---"
+fi
+
+if [[ ! -f "$PACKAGE_LIST" ]]; then
+  echo "Missing package list: $PACKAGE_LIST" >&2
+  exit 1
+fi
+
+if [[ "$MQTT_BROKER_HOST" == "192.168.1.10" ]]; then
+  echo "Warning: MQTT_BROKER_HOST is still the placeholder IP."
+  echo "  Edit it at the top of this script to your container host's real LAN address,"
+  echo "  or the thin client won't show up as Home Assistant entities."
+fi
+
+if [[ "$DIGEST_WEB_URL" == "http://192.168.1.10:8081" ]]; then
+  echo "Warning: DIGEST_WEB_URL is still the placeholder."
+  echo "  Fill it in once Phase 12's digest-web service is deployed. The image builds"
+  echo "  and boots fine without it — the digest workspace just won't load anything."
+fi
+
+if [[ "$ENABLE_VOICE_SATELLITE" == "true" ]]; then
+  echo "Note: ENABLE_VOICE_SATELLITE=true — this image is for a MIC-ENABLED room"
+  echo "  (\"${VOICE_SATELLITE_NAME}\"). Do not flash it to a room without a microphone."
+fi
+
+if [[ "$ENABLE_GESTURE_CONTROL" == "true" ]]; then
+  echo "Note: ENABLE_GESTURE_CONTROL=true — this image is for a CAMERA-ENABLED room."
+  echo "  The camera still stays off until \"enabled\" is set to true in"
+  echo "  /var/lib/thinclient-agent/gesture-config.json on the booted machine."
+fi
+
+echo
+echo "=== Smart Home Thin-Client ISO Builder ==="
+echo "Debian release   : $DEBIAN_RELEASE"
+echo "Kiosk user       : $KIOSK_USERNAME"
+echo "Image hostname   : $IMAGE_HOSTNAME"
+echo "MQTT broker      : ${MQTT_BROKER_HOST}:${MQTT_BROKER_PORT}"
+echo "Home Assistant   : $HA_URL"
+echo "digest-web       : $DIGEST_WEB_URL"
+echo "Steam Link       : $ENABLE_STEAM_LINK"
+echo "Voice satellite  : $ENABLE_VOICE_SATELLITE"
+echo "Gesture control  : $ENABLE_GESTURE_CONTROL"
+echo "Keyboard layout  : $KEYBOARD_LAYOUT"
+echo
+
+# ---------------------------------------------------------------------------
+# 1. Regenerate includes.chroot from configs/ and agent/
+# ---------------------------------------------------------------------------
+echo "--- Regenerating $INCLUDES ---"
+rm -rf "$INCLUDES"
+mkdir -p \
+  "$INCLUDES/etc/greetd" \
+  "$INCLUDES/etc/wayvnc" \
+  "$INCLUDES/etc/thinclient-agent" \
+  "$INCLUDES/etc/firefox/policies" \
+  "$INCLUDES/etc/thinclient-firefox" \
+  "$INCLUDES/usr/local/bin" \
+  "$INCLUDES/opt/thinclient-agent" \
+  "$INCLUDES/opt/gesture-control" \
+  "$INCLUDES/home/${KIOSK_USERNAME}/.config/sway" \
+  "$INCLUDES/home/${KIOSK_USERNAME}/.config/eww" \
+  "$INCLUDES/home/${KIOSK_USERNAME}/.config/mpv" \
+  "$INCLUDES/home/${KIOSK_USERNAME}/.ssh"
+
+# @KIOSK_USERNAME@ and @KEYBOARD_LAYOUT@ are the only templated tokens in the configs.
+# Everything else the hooks need is read at build time from /etc/thinclient-agent/
+# config.env (written below), which live-build copies in via chroot_local-includes
+# *before* it runs chroot_local-hooks — that ordering is what lets the hooks be plain
+# scripts with no outer-shell variables of their own.
+subst() {
+  sed -e "s/@KIOSK_USERNAME@/${KIOSK_USERNAME}/g" \
+      -e "s/@KEYBOARD_LAYOUT@/${KEYBOARD_LAYOUT}/g" "$1" > "$2"
+}
+
+subst "${CONFIGS_DIR}/greetd/config.toml" "$INCLUDES/etc/greetd/config.toml"
+subst "${CONFIGS_DIR}/wayvnc/config"      "$INCLUDES/etc/wayvnc/config"
+subst "${CONFIGS_DIR}/sway/config"        "$INCLUDES/home/${KIOSK_USERNAME}/.config/sway/config"
+subst "${AGENT_DIR}/thinclient-agent.service" "$INCLUDES/opt/thinclient-agent/thinclient-agent.service"
+
+install -m 0755 "${CONFIGS_DIR}/greetd/kiosk-session"  "$INCLUDES/usr/local/bin/kiosk-session"
+install -m 0755 "${CONFIGS_DIR}/sway/digest-browser"   "$INCLUDES/usr/local/bin/digest-browser"
+install -m 0755 "${CONFIGS_DIR}/wayvnc/start-wayvnc"   "$INCLUDES/usr/local/bin/start-wayvnc"
+install -m 0644 "${CONFIGS_DIR}/mpv/mpv.conf"          "$INCLUDES/home/${KIOSK_USERNAME}/.config/mpv/mpv.conf"
+
+# Console (VT/TTY) keymap — separate from Sway's own xkb_layout above, since greetd
+# briefly owns the console before Sway starts, and the maintenance shell (foot, under
+# Sway) already gets the Sway layout either way. /etc/default/keyboard is what
+# console-setup and most desktop layers read for the initial layout.
+mkdir -p "$INCLUDES/etc/default"
+cat > "$INCLUDES/etc/default/keyboard" < "$INCLUDES/home/${KIOSK_USERNAME}/.ssh/authorized_keys"
+  chmod 600 "$INCLUDES/home/${KIOSK_USERNAME}/.ssh/authorized_keys"
+  echo "  Baked an SSH authorized_keys entry for ${KIOSK_USERNAME}."
+else
+  echo "  No SSH_AUTHORIZED_KEY set — SSH admin access will not be possible on this image."
+fi
+
+# ---------------------------------------------------------------------------
+# 2. Runtime config, read by thinclient-agent, the sway session wrapper, and hooks
+# ---------------------------------------------------------------------------
+echo "--- Writing /etc/thinclient-agent/config.env into includes.chroot ---"
+cat > "$INCLUDES/etc/thinclient-agent/config.env" <