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.aiand the10.0.0.xWireGuard 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:
- Allowlist (L1) — the security boundary. A message from anyone
not in
MX_ALLOWLISTis silently ignored (logged once, not spammed). Everything below only runs for an allowlisted sender. - Room (L2) — your own DM with
@kin(the room named inMX_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. - Grammar (L3) — your message is matched against the 11-verb table
above (
askfirst rewrites torun ranger: ...). No match at all → falls through to the free-text answer path, unchanged. - Flag + risk + rate + target (L4) — for a recognized verb:
- the
KIN_MATRIX_VERBSoperator flag (see below) can disable a verb outright; approve/reject/reviseclassify the target inbox item's risk from itskind(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);- a per-sender (30/min) and per-target (5/min) rate limit;
- 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.
- 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 expire — KIN_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:
- Reads the item's LIVE (non-expired) hold — absent or expired → 409.
- 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. - Writes a
high_risk_confirmaudit row (the hold itself wrotehigh_risk_holdwhen Matrix proposed it — the pair brackets the whole flow). - 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'stargetFor+notificationclick): readsdata.id/data.job/data.run;parseInt+isNaNguard; 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 fromj/rlocals, 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
(_storePendingNav → kin-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.
The risk gate — who gets buttons, who deep-links only¶
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:
- Landing page (
templates/landing_js.py): afternavigator.serviceWorker.ready, posts{type: "csrf", token: <meta- csrf-token>}to the active worker on every page load. - Service worker (
static/sw.js): persists the token in the Cache API — reuse thekin-navcache under a second key (/csrf-token), following_storePendingNav's exact pattern. notificationclick(medium-risk tap): reads the cached token via_csrfCacheRead(), sends it asX-CSRF-Tokenon the/api/inbox/<id>/push-actionfetch (default same-origin credentials carry BOTH the SSO cookie and the CSRF cookie —SameSite=Strictcookies 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:
event.action ∈ {"approve", "dismiss"}ANDdata.kind === "inbox"ANDdata.id != nullAND a cached CSRF token is available.- Same-origin
fetchto/api/inbox/<id>/push-actionwith{method: "POST", credentials: "same-origin", headers: {"X-CSRF- Token": <cached>, "Content-Type": "application/json"}, body: JSON.stringify({action})}. - HTTP 2xx: replace the original notification in place via
registration.showNotification(...)with the SAMEinbox-<id>tag (collapses the lock-screen update), body"approved"/"dismissed", NO actions. The "it took" signal. - 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. - 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)¶
- 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).
- Log in as
@blake:matrix.kinra.aiwith the handed-off initial password (Blake changes it after first login). - Push notification smoke — the room should receive a test
message from
@kinwithin 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.) - Round-trip — from the phone:
- 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. - Send
run <workspace>: <prompt>→ the bot posts asubmitted job N (run M)ack, then the run report (the report head + a deep link) when the run settles — both in YOUR DM. - Send
status→ the bot posts a one-line Activity summary. - Send
ask <question>→ the same round-trip asrun ranger: <question>(see "Therangerworkspace" below) — a phone-typed shortcut to the concierge, no workspace name to remember. - 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 (
runis 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)¶
- Register the user on the homeserver (above).
- Add the MXID to
MX_ALLOWLISTin~/kin-textual-build/container/secrets/mx-envon kloud (comma-separated). This file is the singleenv_filefor all fourMX_*vars — never committed, never baked into the image. - Redeploy (
task outpost:deploy-dev) so the container picks up the new allowlist. The/api/messenger/statuspill'sallowlist_sizeconfirms the count. - The user opens a DM with
@kin(any Matrix client, or the API).@kinauto-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-headersis the project-wide security-header middleware (CSP, HSTS, etc.).tls.certResolver: letsencrypt— Traefik's HTTP-01 challenge againstmatrix.kinra.ai(the wildcard already in Cloudflare means the cert request resolves at the VPS).- Verify
traefik.ymlrespondingTimeoutstolerate Matrix/synclong-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/syncround.
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 incontainer/secrets/mx-env. A singleenv_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.aiis private, no-federation, no-open-registration, so unsolicited invites are not a realistic vector. - No federation. Port 8448 is exposed NOWHERE; the
federation_domain_whitelistis empty. A compromised room can never reach a foreign homeserver.
Known-accepted gaps¶
matrix-niorelease cadence is slow (~20 months since v0.25.x). Accepted — the planned swap path if a future Synapse upgrade ever breaks the sync loop ismautrix-python(the adapter is one file by design). The E2E path is unaffected because we don't install thee2eextra.- 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_groupguard — 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_ALLOWLIST — L1 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. |
Related¶
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 secondDeliveryAdapter(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_VERBSsemantics. 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).