Skip to content

Messaging — the Outpost reachability surface

Operator runbook. How Matrix reaches you when kin needs you.

The Outpost messenger is a self-hosted Matrix bot that reaches you on your phone (Element / Element X / FluffyChat — any Matrix client) when a run needs input or a job settles. It is the first real delivery adapter registered with delivery.send; the delivery seam (WP0.3) fans out every inbox_item / run_report / alert event to it. The legacy ntfy webhook path was retired 2026-07-05 (it was dormant config on this box, never deployed); see docs/decisions/0027-matrix-reachability.md for the rationale.

This page documents Blake's own live deployment as the worked example — kloud / matrix.kinra.ai / auth.kinra.ai and the 10.0.0.x WireGuard addresses throughout are its concrete host names and mesh IPs, not requirements. Standing up your own Synapse stack, substitute your own docker host, domain, and mesh addressing.

Stack layout

Everything lives in two places: a Synapse homeserver (the Matrix server) on the kloud Docker host, and a Traefik router on the public VPS that fronts it. The Outpost container is a SEPARATE process — the bot is an in-process adapter of the dashboard's delivery abstraction; it does not live inside Synapse.

kloud:~/matrix/ (the Synapse stack)

A per-stack-dir compose (the local convention — kinra-logto/, logto-postgres/ follow the same shape). Two services:

  • ghcr.io/element-hq/synapse:v1.155.0 — the homeserver, pinned at deploy time. Element-HQ is the canonical home post-takeover; the Docker Hub mirror is deprecation-bound.
  • postgres:17-bookworm — Synapse's database. SQLite is "testing only" in the official posture; Postgres is production from day one. Per-stack Postgres is the kloud convention (Logto + Logto's Postgres is the template).

Port map: 10.0.0.2:8008:8008 — WireGuard-IP binding, the standard kloud service pattern. Verify 8008 is free first (ss -ltn); if not, use the next free port and carry it into the Traefik entry below.

ts-kloud-vps:~/kloud/traefik/dynamic/routes.yml (the public edge)

Append the matrix router to the file-provider (hot-reloads on save):

http:
  routers:
    matrix-kinra:
      rule: "Host(`matrix.kinra.ai`)"
      entryPoints: [websecure]
      middlewares: [crowdsec, secure-headers]
      tls:
        certResolver: letsencrypt
      service: matrix-kinra-svc
  services:
    matrix-kinra-svc:
      loadBalancer:
        servers:
          - url: "http://10.0.0.2:8008"

matrix.kinra.ai is already covered by the *.kinra.ai wildcard Cloudflare record — zero DNS work. The Traefik file-provider HOT-RELOADS on save (traefik.yml has file.directory watching).

homeserver.yaml posture

The deployed homeserver.yaml follows a deliberate "single-operator, no federation" posture. Every choice below is load-bearing — see docs/decisions/0027-matrix-reachability.md for the why.

server_name: matrix.kinra.ai  # IMMUTABLE once users exist
enable_registration: false
federation_domain_whitelist: []
# NO federation listener; port 8448 exposed NOWHERE
max_upload_size: 20M
media_retention:
  local_media_lifetime: 90d
# `registration_shared_secret` lives ONLY in the stack env
# (anyone holding it can register regardless of the flag)

server_name is immutable. Synapse refuses to rename once users exist — the only fix is a database wipe. Lock it deliberately here (no federation + single operator makes the subdomain cosmetic and skips .well-known delegation entirely; clients connect straight to matrix.kinra.ai).

Users

The bot plus one or more operators (and optional automation accounts). Created via register_new_matrix_user or the shared-secret registration API (see "Admin commands"):

  • @kin:matrix.kinra.ai — the bot (the only account the Outpost needs).
  • @blake:matrix.kinra.ai — the operator (admin).
  • @claude:matrix.kinra.ai — an automation/smoke account the AI peer uses to exercise the messenger end-to-end without the operator driving it (see "Smoke testing" below).

Bot access token: minted via the Synapse admin API (POST /_synapse/admin/v1/users/@kin:matrix.kinra.ai/login — no bot password is ever stored on disk or in the stack env). The token is the ONLY credential the Outpost needs; it (with the other three MX_* vars) lives in container/secrets/mx-env on the Outpost container's host — a single env_file (mode 0600, gitignored, NEVER in the repo or the image). Bot exemption from rate limits via POST /_synapse/admin/v1/users/<user>/override_ratelimit — the bot may receive several events per minute from the watcher; without the exemption Synapse 429s it within minutes.

Rooms — multi-operator DMs

The bot supports multiple DMs, one per operator. It auto-joins every room it's invited to (on its next /sync) and routes each reply + run report back to the room the command came from (messenger._resolve_delivery_room reads the originating room from the job's v1_metadata). MX_ROOM is the default/fallback room — where scheduled-job reports, v1-webhook job reports, and alerts land (those have no originating DM). Each operator simply opens their own DM with @kin; no per-operator room config.

Outpost container env

The Outpost reads FOUR env vars on build_app startup. Any missing → the messenger is dormant (zero import of nio, no sync loop, no delivery). The card-messenger Configure pill renders dormant with the missing keys listed.

These are container-level compose env vars, set via container/secrets/mx-env — distinct from the kin CLI's own settings-backed variables documented in Environment variables. The pill (dormant / ok / error) lives on the dashboard's Configure tab, Messenger card.

Var Example Purpose
MX_HOMESERVER https://matrix.kinra.ai homeserver URL
MX_TOKEN syt_<bot_access_token> bot access token (from admin API)
MX_ROOM !ops:matrix.kinra.ai the private room id
MX_ALLOWLIST @blake:matrix.kinra.ai,@claude:matrix.kinra.ai comma-separated MXIDs allowed to drive commands (run / status / ask / inbox answers)

Commands

Eleven verbs, one alias, and the free-text fall-through. Every command goes through a 5-layer gate before it does anything — see "The 5-layer gate" below for what each layer checks and why.

Verb Shape What it does Risk
help help This reference, posted back to the room. read
status status One-line Activity summary (5 most-recent items + open-inbox count). read
list list [inbox\|jobs\|schedules] Top-10: open inbox items (default), jobs, or standing/upcoming schedules. read
run run <workspace>: <prompt> Submits <prompt> as a one-shot job against <workspace>. Acks submitted job N (run M), then delivers the run report to the same room. medium
ask (alias) ask <question> Sugar for run ranger: <question> — the concierge workspace (see below). A bare ask with no question gets a usage reply. medium
approve approve <item_id> [note] Answers an inbox item "approved" (or "approved: <note>"), then resumes the halted run. by item kind
reject reject <item_id> [reason] Answers an inbox item "rejected" (or "rejected: <reason>"), then resumes. by item kind
revise revise <item_id>[:] <text> Answers an inbox item with free text, then resumes. by item kind
dismiss dismiss <item_id> Dismisses an inbox item — no resume, the run stays halted. medium
pause pause <job_id> Disables a scheduled job (its schedule — no run is touched). medium
resume resume <job_id> Re-enables a scheduled job. medium
stop stop <job_id> Kills a job's queued/running run (SIGKILLs the process group) — the schedule itself is untouched for a recurring job. A job with nothing currently in flight replies "already finished" rather than pretending to act. medium
(free text / a bare number) Answers the most-recent open Inbox item (N: <answer> picks a specific item when several are open). This is the original, pre-grammar loop — it still works exactly as before. ungated (see below)

Two different namespaces, on purpose: approve/reject/revise/ dismiss take an inbox ITEM id (the number the Inbox card shows on each row); pause/resume/stop take a JOB id (the number on a Jobs-tab row). A halted RUN is never resumed by id — you resume it by answering its inbox item (approve/reject/revise, or free text). resume 7 un-pauses job 7's future schedule; it does nothing to a run that's sitting in the Inbox waiting on you. pause and stop are also distinct: pause 7 disables job 7's schedule (no future fires, current run untouched); stop 7 kills whatever job 7 is doing right now (a queued or running run) without touching a recurring job's schedule.

The 5-layer gate

Every inbound message from an allowlisted operator passes through, in order:

  1. Allowlist (L1) — the security boundary. A message from anyone not in MX_ALLOWLIST is silently ignored (logged once, not spammed). Everything below only runs for an allowlisted sender.
  2. Room (L2) — your own DM with @kin (the room named in MX_ROOM) is always trusted. Any OTHER room the bot's been invited into (a shared/group chat) is only trusted when EVERY member is the bot or an allowlisted operator — a single outsider in the room refuses every command with a typed reason, so a shared room can't leak what an operator's commands are doing.
  3. Grammar (L3) — your message is matched against the 11-verb table above (ask first rewrites to run ranger: ...). No match at all → falls through to the free-text answer path, unchanged.
  4. Flag + risk + rate + target (L4) — for a recognized verb:
  5. the KIN_MATRIX_VERBS operator flag (see below) can disable a verb outright;
  6. approve/reject/revise classify the target inbox item's risk from its kind (question/elicitation → medium, dispatches immediately; approval/plan/proposal, or an item with no readable kind at all → high — see "The high-risk hold" below for what happens instead of a direct dispatch);
  7. a per-sender (30/min) and per-target (5/min) rate limit;
  8. the target itself has to actually exist (and, for approve/reject/ revise, still be open) — a typo'd id gets a friendly "no such item/job" reply instead of a crash.
  9. Execute (L5) — the command runs, an audit row is written (approve/reject/revise/dismiss/run/pause/resume/stop/ the free-text answer all leave a trail — nothing that mutates state goes unlogged), and the room gets a confirmation.

The free-text answer path is deliberately NOT risk-gated. A typed reply is deliberate by construction — the high-risk hold exists to protect one-tap actions (a lock-screen push approve, a two-character approve 7) from a fat-finger, not to slow down someone who typed a full sentence.

The high-risk hold — Matrix proposes, the dashboard confirms

A approve/reject/revise classified high doesn't dispatch and doesn't dead-end either — it holds. inbox.hold() writes an additive held_json column on the inbox item (the item's state stays open — no new state, no schema rebuild) carrying the verb, the exact D8-canonical answer text a direct dispatch would have posted ("approved" / "approved: <note>" / "rejected[: reason]" / the raw revise text), the sender's MXID, and the room to reply in. The bot replies:

`approve` on item 42 is high-risk — held for dashboard confirmation —
https://outpost.kinra.ai/inbox/42

Replace semantics: a second high-risk command on the same item overwrites the first hold (last intent wins) — typing approve 42 then changing your mind to reject 42 before ever opening the dashboard holds the reject, not the approve.

Holds expireKIN_MATRIX_HOLD_TTL (container env, seconds, default 3600) bounds how long a hold stays confirmable; an expired hold reads as "no hold" everywhere (the dashboard badge doesn't render, POST /api/inbox/{id}/high-risk-confirm 409s) even though the column still carries the stale JSON until the next answer/dismiss clears it.

A dashboard answer or dismiss during a hold WINS. Answering or dismissing the item from ANY surface — including the plain POST /api/inbox/{id}/answer route, not just the confirm route — NULLs the hold. This is deliberate: the hold is a proposal, not a lock, and the operator directly acting on the item is a strictly more explicit signal than a Matrix message.

Confirming: the dashboard landing page's Needs You card shows a "pending approval" badge on a held item; selecting it arms a Confirm held approval button on the sticky action bar (alongside the normal Approve/Reject/Revise/Open run) instead of requiring you to re-type the answer. Confirming posts to POST /api/inbox/{id}/high-risk- confirm (CSRF-gated, no body needed — everything lives in the hold), which:

  1. Reads the item's LIVE (non-expired) hold — absent or expired → 409.
  2. Applies it through the SAME inbox.answer() + scheduler.resume_run() composition the plain answer route uses (via="dashboard-high-risk- confirm") — no new mutation path, just a different route that supplies the already-decided answer.
  3. Writes a high_risk_confirm audit row (the hold itself wrote high_risk_hold when Matrix proposed it — the pair brackets the whole flow).
  4. Replies in the room the hold came from (not necessarily MX_ROOM — a DM-originated hold confirms back to that DM), so the operator sees the loop close on their phone.

The run-detail page (/runs/<job>/<run>) also shows a "pending high-risk approval" note when the open item carries a live hold — the free-text answer form there stays functional (answering directly still wins and moots the hold, per the rule above); the one-tap confirm button lives on the landing page's action bar.

KIN_MATRIX_VERBS (container env, alongside the MX_* vars, in container/secrets/mx-env) narrows which verbs are live: enabled (default — everything above), legacy (only run/status/ask + the free-text fall-through — effectively the pre-Step-2 bot), or a comma-list of verb names (e.g. run,status,list). Read once at container boot; redeploy to change it.

KIN_MATRIX_HOLD_TTL (container env, seconds, default 3600) sets how long a high-risk hold (above) stays confirmable before it silently expires. Unlike KIN_MATRIX_VERBS it's read fresh on every hold check rather than cached at adapter construction (no functional difference on the container, which sources both from container/secrets/mx-env at boot and needs a restart to pick up a change either way — it just means a future non-container caller of the same code wouldn't need one).

Push notifications — the one-tap lane (D5, amended by DR 0063 + DR 0064 + DR 0065)

Matrix is the primary channel — it's where run reports, alerts, and inbox pings all land by default, and it's the only surface that can issue commands (the grammar above). Browser Web Push is a secondary lane: a one-tap lock-screen notification for ANY of the three EVENT_KINDS (inbox item, run report, alert), meant for a device that isn't in MX_ALLOWLIST, or for an operator who wants a lock-screen tap instead of opening Element. DR 0064 (Step 2) extends the inbox lane with one-tap Approve / Dismiss buttons on the lock screen, gated by the r19 risk classifier — the phone stops being an information surface and becomes an action surface, at least for the low/medium-risk lane. It's the second delivery.DeliveryAdapter (container/dashboard/push.py), registered next to the Matrix adapter at the SAME fan-out seam (delivery.send) — a dead push service can never break the Matrix delivery, and vice versa.

The wire is a typed, sanitized envelope. push.WebPushAdapter's per-kind envelope is the full contract table below. Two structural disciplines: the no-url-on-the-wire rule (navigation paths are constructed SW-side from validated numeric fields only — Step 4's per-kind deep-link grammar — a wire-supplied URL navigated by the SW would be a new open-redirect-shaped sink), and the size discipline (title ≤200 chars, body ≤120 chars keeps the envelope far under the APNs 4 KB ceiling). The body is the caller's pre-sanitized summary re-passed through push._sanitize belt-and-suspenders — exactly like MatrixMessenger.deliver's defense-in-depth pass — so model text NEVER rides the wire (the DeliveryEvent pre-sanitized contract is the licence; the adapter-side pass is the safety net).

Payload contract — kind → wire → tag → tap

Kind Wire JSON (sent over pywebpush) tag Constructed tap target (DR 0065)
inbox_item (medium-risk — DR 0064) {"type": "inbox", "id": <item_id>, "title": <str≤200>, "body": <str≤120>, "actions": [{"action":"approve","title":"Approve"},{"action":"dismiss","title":"Dismiss"}]} inbox-<id> One-tap Approve / Dismiss on the lock screen (medium-risk items only). A successful tap posts POST /api/inbox/<id>/push-action (CSRF-gated, server-side risk re-check); on 2xx the original notification is replaced with "approved" / "dismissed" body (no actions). On any failure the SW falls back to /inbox/<id> deep-link + focus/openWindow. Action-less tap keeps the open-the-item behavior.
inbox_item (high / unknown risk) {"type": "inbox", "id": <item_id>, "title": <str≤200>, "body": <str≤120>} (NO actions key — fail-closed at the wire) inbox-<id> Opens /inbox/<id> — selects the row, arms Approve / Reject / Revise on the sticky action bar (the r20 hold+confirm surface for high-risk items).
run_report {"type": "run_report", "job": <int>, "run": <int>, "title": <str≤200>, "body": <str≤120>} run-<job>-<run> (per-instance — two distinct runs do NOT collapse) /runs/<job>/<run> (DR 0065) — the server-rendered run-detail page (app._run_view); the page carries a /#activity back-link so leaving the SPA is fine. A missing/NaN job or run degrades to /.
alert {"type": "alert", "title": <str≤200>, "body": <str≤120>} alert (kind-level — repeated alerts for the same broken schedule collapse into one update, by design) /#card-jobs (DR 0065) — the Jobs tab, consumed by the existing _applyHash vocabulary.

Why the wire type for inbox_item stays "inbox" (not "inbox_item"): installed PWAs update their service worker LAZILY — an old SW receiving the new envelope still parses type === "inbox" and reads id, ignoring the new title/body/actions fields (JavaScript object access is lenient about extra keys). The generic inbox notification renders; the deep-link falls back to /inbox/<id>. A new SW that DIDN'T ship with the actions parser renders the notification without buttons (acceptable during the deploy-window pollution); a new SW that DID ship with it gates buttons on the actions array's presence. The deploy-window pollution window closes as soon as the next service-worker install fires.

No model text on the wire. Body text is the pre-sanitized summary the dispatcher stamps (the run report's head for run_report, the question + options block for inbox_item, the alert reason for alert) — all already passed through sanitize_model_id at capture. Belt-and-suspenders re-sanitize at the adapter enforces the cap. The action-button titles ("Approve" / "Dismiss") are static strings — no model text, no server-controlled label, the SW hardcodes the body on confirmation ("approved" / "dismissed", the D8 canonical answer strings).

Tap target grammar — constructed from validated numeric fields (DR 0065)

The action-less tap path constructs the navigation path SW-side from validated numeric fields on the wire envelope — targetFor(data) in container/dashboard/static/sw.js:

Kind Constructed target (in notificationclick / _navCheck)
inbox (id) /inbox/<id>
run_report (job, run) /runs/<job>/<run>
alert (no id) /#card-jobs (consumed by the existing _applyHash Jobs-view branch)
unknown / missing field / (degrade safely, never an open-redirect-shaped error)

The display-sink-adjacent invariant: a wire-supplied URL string navigated by the SW would be a new open-redirect-shaped sink (display-sink rule from REFERENCE.md § Display sinks, applied to the SW's navigation channel). Both ends of the channel construct the path from validated numeric fields only:

  • SW side (sw.js's targetFor + notificationclick): reads data.id / data.job / data.run; parseInt + isNaN guard; a missing or NaN field degrades to /.
  • Landing side (templates/landing_js.py's _applyNavMessage + _navCheck): re-validates ints page-side (parseInt(d.job, 10) + isNaN(j) || isNaN(r)); the path is built from j / r locals, never from a wire-string concatenation.

The new SW → page message shape ({type: "nav", kind, id, job, run}) is what notificationclick posts to a landing client for run_report and alert taps. The existing inbox-nav shape stays byte-compatible for already-open landing pages during the deploy window (a pre-Step-4 client with a Step-4 SW still gets the inbox deep-link; a Step-4 client with a pre-Step-4 SW still gets the inbox deep-link too — both grammars carry the inbox path). The Cache-API write (_storePendingNavkin-nav under /pending-nav) extends the persisted shape to {kind, id, job, run, ts} so the iOS killed/suspend-race _navCheck resume covers all three kinds, not just inbox.

scheduler._emit_inbox_delivery (the funnel behind every inbox push) stamps meta["risk"] = chat.gate.classify_risk("approve", inbox_item) where the freshly-created inbox_item is in hand. The r19 classifier returns:

Item's questions[].kind (synthesized across the set) risk Wire actions? Lock-screen affordance
{question} / {elicitation} (and ONLY those kinds) medium YES — Approve / Dismiss One-tap
Any of {approval, plan, proposal} high NO Deep-link to /inbox/<id> (r20 hold+confirm surface)
No questions / unrecognized kind / missing kind high (fail-closed) NO Deep-link to /inbox/<id>

Server-side, POST /api/inbox/{id}/push-action re-runs the SAME classify_risk against the LIVE item — meta.risk on the wire is just the gate; the route is the truth (a stale or forged envelope cannot bypass the live re-check).

D8 canonical answer strings — one answer service, three surfaces

The push-action lane posts the canonical D8 answer text to inbox.answer"approved" for approve, NOT a synthesized "approved via push-action" wire string. The answer text on the row is exactly "approved" (or "dismissed"); provenance rides inbox.answer's existing via parameter (via="push-action") plus a push_approve / push_dismiss audit row. This is the same discipline the dashboard's INBOX_ANSWER_APPROVED / INBOX_ANSWER_REJECTED JS constants in templates/landing_js.py enforce — three one-tap surfaces (dashboard action bar, Matrix approve/reject verbs, lock-screen push), ONE canonical answer text, audit-only provenance. The pinned D8 string means future surfaces (a hypothetical iOS native app, a future CLI verb) can't drift on "the same tap" — they all post "approved", they all stamp via=<surface>.

CSRF hand-off — equivalent exposure, not a new sink

The push-action route is CSRF-gated (like every other mutating dashboard route). The CSRF cookie is HttpOnly + SameSite=Strict (csrf.py), so the service worker cannot read it directly — not via document.cookie, not via the Cookie Store API. The hand-off:

  1. Landing page (templates/landing_js.py): after navigator.serviceWorker.ready, posts {type: "csrf", token: <meta- csrf-token>} to the active worker on every page load.
  2. Service worker (static/sw.js): persists the token in the Cache API — reuse the kin-nav cache under a second key (/csrf-token), following _storePendingNav's exact pattern.
  3. notificationclick (medium-risk tap): reads the cached token via _csrfCacheRead(), sends it as X-CSRF-Token on the /api/inbox/<id>/push-action fetch (default same-origin credentials carry BOTH the SSO cookie and the CSRF cookie — SameSite=Strict cookies ride same-origin SW fetches).

Equivalent exposure rationale: the token is already same-origin- script-readable via the meta tag (the same capability the SW inherits through the postMessage channel). Cache API access requires the same same-origin-script capability. This is NOT a security regression — the SW has access only because it inherits the page's origin. The CSRF invariant (csrf.py's docstring: never fold ensure back into mint) is preserved — this step READS the token, never re-mints. csrf.ensure reuses the existing cookie value when present, so the cached copy stays valid; if the cookie is ever cleared, the next page load re-sends the fresh token. The full pass: csrf.ensure (read) → postMessage (page → SW) → caches.put (SW cache) → fetch (SW → route) → verify (route reads cookie, compares header). Zero changes to the verify path itself.

Fallback discipline — no retry loop, no false success

The SW's notificationclick handler follows a strict success-or- deep-link ladder on the action lane:

  1. event.action ∈ {"approve", "dismiss"} AND data.kind === "inbox" AND data.id != null AND a cached CSRF token is available.
  2. Same-origin fetch to /api/inbox/<id>/push-action with {method: "POST", credentials: "same-origin", headers: {"X-CSRF- Token": <cached>, "Content-Type": "application/json"}, body: JSON.stringify({action})}.
  3. HTTP 2xx: replace the original notification in place via registration.showNotification(...) with the SAME inbox-<id> tag (collapses the lock-screen update), body "approved" / "dismissed", NO actions. The "it took" signal.
  4. ANY failure — missing cached token, network error, non-2xx (incl. 403 / 409 / 302) — falls back to the existing deep-link path (_storePendingNav + focus/openWindow). The operator lands on /inbox/<id> and sees the truth (a 409 from the route, for example, means the item is already answered/dismissed and the dashboard shows the answered/dismissed state). No retry loop.
  5. An action-less tap (the user taps the notification body, not a button) keeps Step 1's existing behavior byte-for-byte.

Route path — /api/inbox/{id}/push-action, not /api/push/inbox/<id>/action

A deliberate deviation from the brief: the push-action route lives in inbox_api.py (registered beside its three siblings: _answer, _dismiss, _high_risk_confirm), NOT in push.py under /api/push/inbox/<id>/action. The reasoning: every inbox-store mutation (answer / dismiss / high-risk-confirm) already lives behind the same _util.require_json_body preamble in inbox_api.py; scattering the answer composition into push.py would make a future audit of "who can answer an inbox item" a multi-file grep. Same auth posture either way (behind the oauth2-proxy SSO gate, NOT under the /api/v1/ skip-auth regex; CSRF-checked through the standard _util.require_json_body path).

Setup (dormant until a VAPID key exists):

uv run --extra outpost python -m py_vapid --gen   # writes private_key.pem + public_key.pem
mv private_key.pem container/secrets/vapid-private-key
chmod 600 container/secrets/vapid-private-key
rm -f public_key.pem   # the dashboard derives the public key from the private one, on demand

Redeploy. The private key is a Docker file secret (compose.yaml's secrets:, normalized by start.sh the same way as the three oauth- secrets) — it is exported into the container as a file path* (VAPID_PRIVATE_KEY_FILE), never as the key value itself, and never appears in an env dump, a log line, or docker inspect. VAPID_SUBJECT (a plain compose env var, not a secret — the RFC 8292 sub claim, an operator contact URI) defaults to mailto:blake@kinra.ai; override per deployment.

Once the key is present the Configure dashboard's Push notifications card (card-push) flips from dormant to ready and shows the subscribed-device count. Click Subscribe this browser: it requests notification permission, calls pushManager.subscribe with the card's derived public key, and POSTs the resulting endpoint + keys to POST /api/push/subscribe (CSRF-gated, like every other mutating route). GET /api/push/status is the card's read side (badge + public key + count); POST /api/push/unsubscribe tears a subscription down. A subscription that the push service reports as gone (HTTP 404/410 on send — the browser cleared storage, revoked permission, or the OS pruned it) is pruned server-side automatically; a transient failure (a 5xx) is logged and retried on the next fan-out tick instead.

The iOS constraint. A web app's service worker only runs — and can therefore only receive a push — while the PWA is installed via Add to Home Screen and has been launched from the Home Screen at least once (Safari's iOS 16.4+ Web Push requires this; a push subscribed from a plain Safari tab, never added to the Home Screen, silently never delivers). Background pushes queue at the OS level and deliver once the app has been opened from the Home Screen; there is no way around this on iOS today. See Outpost's "Add to Home Screen" section for the install steps. Desktop Chrome/Firefox/Edge have no such constraint — a subscribed tab (or even a closed one, as long as the browser profile is running) receives pushes normally.

iOS accepted limitation — action taps in killed-PWA states. DR 0033's "tap-through deep link does NOT work on iOS when the PWA is suspended/killed" hypothesis extends to action buttons: if notificationclick doesn't fire at all on a killed-PWA tap, action buttons in that state also won't fire. The tap degrades to "opens the app" — safe (no false success), but it bounds Step 2's iOS value to the states where the SW is alive. Android/desktop Chrome are unaffected. This is an empirical fork for the phone QA pass A (lived check, Step 5), not a reason to skip the rock — the iOS PWA is most often foregrounded or recently-suspended when the operator taps a notification.

Re-engage point (RE-6, matrix-fabric plan): if Element X ships a workable self-hosted iOS push path, this lane demotes behind it rather than being the primary iOS answer forever — revisit during a future review, not pre-emptively.

Client onboarding (CP-B — the round-trip gate)

  1. Install Element Web (or classic Element mobile from the app store). Element X is the newer client — sliding sync is native in Synapse ≥1.114, so the only open question is password login; if password login fails on Element X, classic Element is the documented fallback (NO MAS deployment unless Element X refuses to authenticate).
  2. Log in as @blake:matrix.kinra.ai with the handed-off initial password (Blake changes it after first login).
  3. Push notification smoke — the room should receive a test message from @kin within seconds of sending. Element's hosted Sygnal gateway (plain outbound HTTPS, no federation required) delivers push with the app backgrounded. (This is Element's own native push for the Matrix client itself — a separate mechanism from the dashboard's PWA Web Push lane described above, which is an id-only "kin needs you" tap-through for a browser that isn't running Element at all.)
  4. Round-trip — from the phone:
  5. Send a free-text message to the room while a halted run is open in the Inbox → inbox.answer(via="matrix") fires, the run resumes.
  6. Send run <workspace>: <prompt> → the bot posts a submitted job N (run M) ack, then the run report (the report head + a deep link) when the run settles — both in YOUR DM.
  7. Send status → the bot posts a one-line Activity summary.
  8. Send ask <question> → the same round-trip as run ranger: <question> (see "The ranger workspace" below) — a phone-typed shortcut to the concierge, no workspace name to remember.
  9. A scheduled job with message me on Matrix checked (the Jobs tab create/edit form) posts its run report to your DM on each terminal completion — a recurring loop with no phone interaction (run is the one-shot version of the same thing).

The ranger workspace — the concierge alias

ask <question> is pure sugar for run ranger: <question> — the run and status commands and the inbox-answer default routing are byte-identical either way (container/dashboard/messenger.py's _on_message dispatch rewrites ask … into the run ranger: … path before anything else runs). If the ranger workspace doesn't exist on the box yet, ask fails with the exact same "workspace 'ranger' not found" error run ranger: … would — the alias adds no new failure mode of its own.

ranger is a workspace name, not a new bot or a persona. The bot identity stays @kin — the middle-way plan's constitution amendment (prohibition 1: persona as context, never as surface) rules out a second Matrix account or a "Ranger" character. What makes ranger different from any other workspace is only its AGENTS.md: a concierge brief that scopes Kin to the automation layer — jobs, runs, schedules, alerts, the inbox — reached in-container against 127.0.0.1:7681 (same v1 middleware chain, bearer still required; oauth2-proxy only fronts :4180, so this is not an auth bypass). compose_system picks the workspace AGENTS.md up automatically, the same way it does for any other workspace.

The canonical text (lands on the box at ranger/AGENTS.md, per the middle-way plan's Step 7):

you are the concierge of this Outpost's automation layer; your job is
jobs, runs, schedules, alerts, and inbox items — use the `outpost` tool
for ground truth, never guess; answer briefly (Matrix, mobile); when
asked to change things (submit/cancel/answer), confirm intent and let
the permission flow do its job; when asked to do real work — code,
writing, analysis — say that belongs in kin on the laptop and name what
to open there. You are not a character; you are Kin working the front
desk.

Accepted consequence: container-global settings mean any scheduled job on the box could call the outpost read tool (auto mode → ALLOW) — one operator, one trust domain, and KIN_SANDBOX=container already trusts this boundary (see Trust the container). Mutations still ASK — a submit/cancel/answer halts and lands as an Inbox item for Blake to confirm, which for a concierge is the correct escalation, not a defect.

Admin commands (the operator cheatsheet)

All admin calls are HTTPS POSTs against https://matrix.kinra.ai/_synapse/admin/v1/.... The admin token is the registration_shared_secret from the stack env (rotation incident-only; see docs/decisions/0027-matrix-reachability.md).

# Register a new user (run inside the synapse container; -c reads the
# shared secret from homeserver.yaml). Omit --admin for a plain user.
docker exec -it matrix-synapse register_new_matrix_user \
  -u <name> -p <password> -c /data/homeserver.yaml http://localhost:8008

# Mint a bot access token (no password)
curl -X POST -H "Authorization: Bearer <admin_token>" \
  https://matrix.kinra.ai/_synapse/admin/v1/users/@kin:matrix.kinra.ai/login

# Override ratelimits for a user (the bot fires at ~1 event/min)
curl -X POST -H "Authorization: Bearer <admin_token>" \
  https://matrix.kinra.ai/_synapse/admin/v1/users/@kin:matrix.kinra.ai/override_ratelimit

For non-interactive registration (scripts, automation), the shared-secret registration API is deterministic — register_new_matrix_user's MAC sequence is nonce\0user\0password\0(notadmin) HMAC-SHA1 keyed by the shared secret; POST it to /_synapse/admin/v1/register.

Add an operator (or automation account)

  1. Register the user on the homeserver (above).
  2. Add the MXID to MX_ALLOWLIST in ~/kin-textual-build/container/secrets/mx-env on kloud (comma-separated). This file is the single env_file for all four MX_* vars — never committed, never baked into the image.
  3. Redeploy (task outpost:deploy-dev) so the container picks up the new allowlist. The /api/messenger/status pill's allowlist_size confirms the count.
  4. The user opens a DM with @kin (any Matrix client, or the API). @kin auto-joins on its next /sync — no manual room setup.

Smoke testing (no operator phone required)

@claude:matrix.kinra.ai is a dedicated smoke account so the AI peer can drive the full round-trip (DM → run → ack → report) without the operator. Its access token lives at ~/.kin/outpost-claude-token (mode 0600, gitignored by location — NOT in the repo). The smoke:

uv run python scripts/outpost_messenger_smoke.py "run scratch: say hi"
# or, the Taskfile alias (same script):
task live-messenger

It opens a fresh DM, invites @kin, sends the command, and polls for the ack + run report. Exit 0 = ≥1 bot reply, 2 = timeout. Add the account to MX_ALLOWLIST first (above) or the bot ignores it. task live-messenger needs ~/.kin/outpost-claude-token (or KIN_MX_SMOKE_TOKEN) to already be minted — pass extra script args after --, e.g. task live-messenger -- --ask "what's scheduled today?". It joins live-jobs / live-outpost-tool as one of the pass's manual live gates (see container/CLAUDE.md § "The outpost live-gate posture").

Traefik entry (long form)

The full routes.yml entry above is the canonical form. Notes:

  • middlewares: [crowdsec, secure-headers] — CrowdSec blocks abusive IPs at the edge (same protection every other Kinra service inherits); secure-headers is the project-wide security-header middleware (CSP, HSTS, etc.).
  • tls.certResolver: letsencrypt — Traefik's HTTP-01 challenge against matrix.kinra.ai (the wildcard already in Cloudflare means the cert request resolves at the VPS).
  • Verify traefik.yml respondingTimeouts tolerate Matrix /sync long-polling. The default 30s read timeout is fine; if Traefik is configured for stricter timeouts, bump them — the bot holds a single long-poll for up to 30s on each /sync round.

Threat notes

  • Homeserver-trusted. No E2E encryption in v1 (we skip the matrix-nio[e2e] extra entirely; the libolm/vodozemac churn is avoided by not installing it). This is a single-operator, private homeserver over TLS — the threat model is "operator's phone reads their own bot's notifications", not "secrets from a hostile party".
  • Allowlist = operator MXIDs only. The bot ignores every message from a non-allowlisted sender and logs once per sender (not per message — a flapping script can't spam the log). A malicious MXID who somehow learned the room id can READ but not DRIVE — run, status, and the inbox-answer path are all gated by the allowlist.
  • MX_* config lives in container/secrets/mx-env. A single env_file (mode 0600, gitignored — container/secrets/* except .gitkeep) holding all four vars; same secrets pattern as oauth2-proxy + cookie-secret. The bot token is revoke-and-reissue on suspicion — the bot re-logs on next boot.
  • Auto-join is safe under the allowlist. The bot joins every room it's invited to, but only ACTS on allowlisted senders — a joined room with no allowlisted command is inert. matrix.kinra.ai is private, no-federation, no-open-registration, so unsolicited invites are not a realistic vector.
  • No federation. Port 8448 is exposed NOWHERE; the federation_domain_whitelist is empty. A compromised room can never reach a foreign homeserver.

Known-accepted gaps

  • matrix-nio release cadence is slow (~20 months since v0.25.x). Accepted — the planned swap path if a future Synapse upgrade ever breaks the sync loop is mautrix-python (the adapter is one file by design). The E2E path is unaffected because we don't install the e2e extra.
  • Multi-room is live (was: "one private room"). The bot now auto-joins DMs + routes replies and reports per-room (see Rooms above). The earlier room.is_group guard — which actually meant "unnamed room" and silently dropped unnamed DMs — was removed; the allowlist + invite-auto-join are the boundaries.
  • The bot token has no rotation flow yet. Revoke + reissue on suspicion (manual). WP7+ may add a managed-rotation surface.

Troubleshooting

Symptom Cause / fix
Configure tab's Messenger pill shows error The sync loop hit a Matrix-side error (an invalid/revoked token, a WAF page returned in place of a real sync response, etc.) and is backing off exponentially (1s → 2s → 4s → ..., capped) rather than retrying instantly — a returned error and a raised one both take this path. task outpost:remote-logs surfaces the detail nio reported; the pill flips back to ok on its own once the underlying cause clears, no restart needed.
Dashboard, Matrix, and sign-in all look dead at once, though the container is healthy See Outpost's troubleshooting table — a CrowdSec ban of the box's own egress IP (not just a browser's) 403s every kloud-vps-fronted hostname it calls outbound too, including this bot's /sync loop against matrix.kinra.ai.
@kin never replies, pill stays ok Check the sender's MXID is actually in MX_ALLOWLISTL1 of the 5-layer gate drops anything else silently (logged once, not per-message) — and that the message is either a recognized verb or free text answering an open inbox item; an unrecognized verb with no open item does nothing visible.
run <workspace>: ... acks but no run_report message ever lands The run likely halted on a question instead of completing — a halt delivers a separate "kin needs you" inbox_item message, not a run_report (a run_report only fires on terminal ok). Check list inbox from the room, or the dashboard's Needs You card; answering resumes the run and its eventual completion delivers the report.
  • docs/decisions/0027-matrix-reachability.md — the full design + ntfy retirement rationale.
  • .kin/plans/2026-07-05-outpost-stack-plan.md § WP6 — the planning record.
  • .kin/plans/2026-07-07-matrix-as-communication-fabric-plan.md § Step 2 — the 10-verb grammar + 5-layer gate design (D1-D8); § Step 5 — the push lane design (D5, D6).
  • container/dashboard/messenger.py — the adapter (one file, by design) + the dispatch (_on_message/_dispatch_command).
  • container/dashboard/push.py — the second DeliveryAdapter (Web Push); tests/test_outpost/test_push.py — subscribe/unsubscribe, the pinned id-only payload shape, 404/410 pruning, the dormant path.
  • container/dashboard/chat/ — the grammar (commands.py), identity (identity.py), and gate (gate.py) modules the dispatch composes.
  • REFERENCE.md § "Matrix messenger & the chat gate" — the WHYs behind every layer, the rate-limit shape, and the KIN_MATRIX_VERBS semantics.
  • tests/test_outpost/test_messenger.py — the dispatch/gate/audit integration coverage (allowlist, room auth, flag, risk, rate, target-state, D8 literal pin, run/status/ask/answer regressions).
  • tests/test_outpost/test_chat_commands.py — the grammar's golden-file parse tests.
  • tests/test_outpost/test_chat_gate.py — the gate layers' pure-function tests (room auth, risk classification, flag resolution, rate limits on an injected clock).