- Feature Name:
attention(chat that never loses a message, and notifications that reach a person wherever they are — hooks become push) - Start Date: 2026-08-29
- Status: Implemented (delivery plan: 0012). Its live model reaches chat, CI, landings, the inbox and the activity stream, and does NOT yet reach a team's room, forum threads, ticket pages or a team's settings surfaces — ticket 82b9615eaf3d. Streaming is the default here, so those four are a debt against this RFC rather than a choice made about them.
- Depends on: RFC 0001 §5 (chat: transport is NATS, durability is the window), §8 (OLTP), §9 (the ops seam), Principles 2, 3, 5, 6; RFC 0004 §4 (capability letters); RFC 0005 (event vocabulary — what may and may not be raised); RFC 0008 (roles)
- Upstream: notify#300 (mark read), notify#301 (count/pagination), notify#302 (Web Push provider), notify#303 (workflow upsert), notify#304 (README); none blocks Phase 0–2 of RFC 0012. dagnats — none expected (§12).
- Findings, with the survey that produced them: https://claude.ai/code/artifact/ed3bb5ca-d523-4e99-8446-3c48ffd82f2a
Summary
Quarry already has the transport for live chat (a JetStream window per repository), the vocabulary for hooks (RFC 0005), durable signed webhooks, and a notify companion that owns delivery. What it lacks is the last mile: nobody is ever subscribed, a mention is fired at a handle nobody checked, the chat stream cannot resume after a hidden tab, and the inbox cannot be marked read.
This RFC adds three things and changes no plane's truth status:
- A chat that cannot lose a message. Every message has a sequence; every
stream carries it; a reconnecting client replays from where it stopped.
Unread is a per-account read pointer in OLTP, the
forum_readshape. Mentions are resolved against accounts before anything is fired. - An attention stream — one per open tab — carrying what needs a person's eye: mentions, replies, verdicts, the inbox count. It works with no notify configured, because the wake signal is a NATS subject quarry owns. When notify is configured, the same stream also carries the inbox.
- Push when no tab is open, delivered by notify through a Web Push provider, with quarry doing the two things only it can: provisioning its accounts into notify, and serving the service worker that receives.
References — ^412, #12, [hash], @alice, across repositories — resolve
through one resolver on every surface and preview in a popover that degrades
to a plain link (§14).
Everything is an op first (Principle 5); the HTML pages are translations of
those ops (§9); every live surface is plain SSE with id: and
Last-Event-ID (Principle 6); and every page still works with JavaScript
off, with one exception this RFC records explicitly (§11).
Fossil's chat is neither foundation nor competitor: it is a connector, bridged per mirror when an operator opts in (§9), and quarry chat is whole without it.
Motivation
Three observations from the survey drive the shape.
Push needs two transports. A tab that is open wants a stream; a tab that is closed wants the operating system to say something. Campfire (Basecamp's ONCE) is the closest analogue to quarry's stack — server-rendered, SSE as enhancement — and it keeps the two strictly separate: Turbo streams for presence, Web Push for absence. Making one transport do both is how chat apps end up with a native client.
Datastar closes GET streams on hidden tabs (openWhenHidden defaults to
false) and stops after ten retries. So a stream that cannot resume will
lose messages in ordinary use — switch tabs, close the laptop — and the loss
is silent. The fix is not on the client: Datastar's SSE client already
re-sends last-event-id on retry (verified in
library/src/plugins/actions/fetch.ts). The fix is a server that puts an
id: on every event and can answer "everything after N".
Notify owns channels; quarry owns who. RFC 0001 §5 drew this line and it holds: digests, preferences, email, SMS, chat providers and idempotent fan-out are all in notify today. The four things a live inbox needs that notify lacks — mark read, unread count, pagination, Web Push — are small and additive there, and would be wrong here.
Guide-level explanation
What a person sees.
On a repository's chat page, messages are grouped by author and minute, with
markdown and highlighted code. A divider marks where they stopped reading.
The repository nav shows an unread count; a mention shows a separate mark.
Typing @ offers the people who can see the repository. Sending does not
reload the page; a message shows as pending, then settles, or shows "not
sent — retry" if it did not. Coming back to the tab after an hour, the
messages posted meanwhile are there, in order, above the divider. Their own
messages can be deleted, or edited within five minutes, and the log says so.
A run of messages can be promoted to a forum thread, which is where anything
worth keeping goes — chat itself still forgets after seven days.
On every page, a small toast says "alice mentioned you in sqlite chat" with a link, the inbox mark in the bar updates, and the tab title gains a count. With notifications allowed in the browser, the same arrives as a system notification while the tab is in the background. With push enabled on a device, it arrives with no tab open at all.
The inbox lists what happened to them — "mentioned you", "replied to a thread you follow", "your landing was accepted" — each with a reason, a link, and a way to mark it done or stop following the thread. Preferences say which kinds arrive immediately, which arrive as a digest, and on which channel.
What an operator sees.
Nothing to curl. Adding an account makes it a notify subscriber; granting
it a repository puts it in that repository's activity topic; starting quarry
ensures the workflows exist. A mirror of a Fossil upstream can, per mirror,
bridge that upstream's chat into the quarry channel and back.
What an integrator sees.
GET /api/v1/r/{repo}/chat/stream and GET /api/v1/me/attention are plain
SSE with JSON data: and monotonic id:; Last-Event-ID resumes. A chat
client, a bot, or a desktop notifier is buildable from the OpenAPI document
alone (Principle 5). The NATS subjects remain available to those who want
them and remain unsupported as a contract (Principle 6).
Reference-level explanation
1. Ops — the API is the feature
Every capability below is registered in the ops registry first; the HTML routes are thin translators of the same ops (§9). Caps follow RFC 0004 §4.
| op | kind | auth | caps | route |
|---|---|---|---|---|
chat.history |
Query | anonymous | C |
GET /api/v1/r/{repo}/chat — gains before (seq cursor) and limit; each message carries seq; response carries next |
chat.stream |
Query (SSE) | anonymous | C |
GET /api/v1/r/{repo}/chat/stream — id: <seq>, honours Last-Event-ID |
chat.post |
Command | token | C |
POST /api/v1/r/{repo}/chat — text, optional ref (reply-to seq); returns seq; resolves mentions and references |
chat.edit |
Command | token | C (own, ≤5 min) |
POST /api/v1/r/{repo}/chat/{seq}/edit |
chat.delete |
Command | token | C (own) / a (any) |
POST /api/v1/r/{repo}/chat/{seq}/delete |
chat.read |
Command | token | C |
POST /api/v1/r/{repo}/chat/read — seq (default: latest) |
chat.unread |
Query | token | C |
GET /api/v1/me/chat/unread — per-repo {unread, mentions, since} |
chat.get |
Query | anonymous | C |
GET /api/v1/r/{repo}/chat/{seq} — one message, folded, with its replies; the permalink; says expired past the window |
ref.resolve |
Query | anonymous | per target | GET /api/v1/r/{repo}/ref?ref= — resolves one reference token to {kind, id, title, summary, state, href, expired}; the preview card's data (§14) |
chat.promote |
Command | token | C + 3 |
POST /api/v1/r/{repo}/chat/promote — from, to seq → opens a forum thread; returns the thread root |
chat.robot |
Command | token | a |
POST /api/v1/r/{repo}/chat/robot — event patterns (webhook syntax) or empty to disable |
people.mention |
Query | token | h |
GET /api/v1/r/{repo}/mention?q= — handles with any cap on the repo, ≤10 |
me.attention |
Query (SSE) | token | login | GET /api/v1/me/attention — id: <seq>, Last-Event-ID |
inbox.list |
Query | token | p |
GET /api/v1/me/inbox?before=&limit= — proxies notify; adds reason |
inbox.mark |
Command | token | p |
POST /api/v1/me/inbox/mark — ids or all, as: read\|done |
inbox.count |
Query | token | p |
GET /api/v1/me/inbox/count |
me.prefs / me.prefs.set |
Query / Command | token | p |
GET\|POST /api/v1/me/prefs — family × channel matrix, mute per repo |
me.push.subscribe / .unsubscribe |
Command | token | p |
POST /api/v1/me/push / .../delete — a browser PushSubscription |
mirror.chatbridge |
Command | token | a |
POST /api/v1/r/{repo}/mirror/chatbridge — url, user, password or detach |
chat.post changes from Auth: Anonymous to Auth: Token. Anonymous
reading stays; anonymous writing was never gated at the API and is a
mentions-from-"anonymous" bug, not a feature.
One namespace for async shapes (§9): chat.stream and me.attention
emit the same chatMessage and attentionEvent structs the OpenAPI document
describes. The HTML pages' /r/{repo}/chat.sse and /me.sse emit
datastar-patch-elements rendered from those structs — same op, second
translator.
2. The chat plane, addressed by sequence
The message identity is the JetStream sequence on chat.<code>. Nothing new
is minted; the number already exists and is monotonic per channel.
type Message struct {
Seq uint64 `json:"seq"`
Kind string `json:"kind"` // "msg" | "edit" | "del" | "robot"
Ref uint64 `json:"ref,omitempty"` // edit/del: the seq it replaces
Repo string `json:"repo"`
User string `json:"user"`
Text string `json:"text"`
Origin string `json:"origin,omitempty"` // "" | "fossil" | "robot"
At string `json:"at"` // RFC3339Nano UTC
}
- Append-only. Edit and delete are new messages that reference an
earlier
seq; the reader folds them. History stays honest, replay stays trivial, and nothing needs a delete permission on the stream. - Replay is an ordered consumer, not the core subscription. Today
Subscribedrops on backpressure with no marker. A per-client ordered consumer starting atLast-Event-ID + 1(or at "now" for a fresh page) never drops and never needs the 32-slot buffer. CoreSubscribestays forWatch-style "something changed" ticks, where dropping is correct. - Paging backward is
before=<seq>: an ordered consumer withStartSequencewalking a bounded range — the same contract Fossil'schat-poll?before=offers. - Window semantics are unchanged (RFC 0001 §5): 7 days / 10 000 per
channel. A read pointer older than the window simply means everything
visible is unread. Anything that must outlive the window goes through
chat.promoteinto the forum, where it is an artifact and syncs.
3. Read-state and unread
CREATE TABLE IF NOT EXISTS chat_read (
repo TEXT NOT NULL,
account TEXT NOT NULL,
seq INTEGER NOT NULL, -- highest seq the account has seen
at TEXT NOT NULL,
PRIMARY KEY (repo, account)
);
The forum_read shape, one row per channel rather than per thread, and the
same write guard: a re-visit that advances nothing is a read, not a write.
Unread count is last seq on the subject − seq, one GetLastMsg call, no
scan. The mention mark is separate (§4), because "unread" and "for me" are
different questions and every chat app that conflates them is muted within a
week.
chat_read is authoritative per-account state in the sense of RFC 0001 §8:
small, OLTP, not derivable from the DAG. It passes disposability (§11) the
way forum_read does — a wiped OLTP forgets where people were, which is a
loss of convenience, not of truth.
4. Mentions, resolved
mentions() today returns every @token. It will return only tokens that
name a real account with any EFFECTIVE capability on the repository —
explicit grant or category default alike (RFC 0008's grants feed
EffectiveCaps, which is the oracle, so on a public repo this is "any real
account", since the public-read default already gives every account
something). Unknown handles render as plain text and fire nothing. The
mention is recorded before it is raised:
CREATE TABLE IF NOT EXISTS mention (
repo TEXT NOT NULL, ref TEXT NOT NULL, -- chat:<seq> | forum:<hash>
who TEXT NOT NULL, by TEXT NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (repo, ref, who)
);
which makes chat.mentioned an operational event under RFC 0005 §2.1 —
a fact about a person, backed by a row — and the one chat event this RFC
adds to the vocabulary. chat.posted is deliberately not added: it is
neither derived from the DAG nor operational state, it would put every line
of chat on every * webhook, and Principle 6 already lets an integrator who
wants the firehose read QUARRYCHAT.
Idempotency keys become address-based: mention:chat:<repo>:<seq>:<who>
and mention:forum:<hash>:<who>, never text-based.
5. The attention stream
A small JetStream stream, QUARRYATTN, subject attn.<account>, 30 days /
1 000 per subject. fireNotify publishes there first, for every event
addressed to a person (mention, reply, landing verdict, CI verdict on a
landing they opened), and then triggers notify if configured. The order
matters: an open tab is told by quarry, not by a round trip through notify,
and a forge with no notify still tells people things.
GET /api/v1/me/attention tails attn.<account> from Last-Event-ID.
GET /me.sse renders the same events as Datastar patches:
#inbox-dot— count and title (frominbox.countwhen notify is configured, else from the stream's unread tail);#toasts— an appended<aside role="status" aria-live="polite">with the link, self-dismissing;- a
titlesignal the base layout reflects as(3) quarry; - when the page is visible and the browser has granted permission, a
Notification— the first-time prompt is only ever shown from an explicit control in preferences, never on page load.
The stream is opened by the base layout on every page. On plain HTTP/1.1 a
browser allows six connections per origin, and a repository page already
opens one or two streams; RFC 0012 Phase 2 requires h2 in front (TLS or
h2c at the proxy) before the stream goes on every page, and gates it to repo
pages otherwise. This is a deployment constraint, not a design choice, and
docs/running.md will say so.
6. Push when no tab is open
Quarry serves two static things: /sw.js (receive a push → showNotification
with the link; click → focus or open the tab) and a preferences control that
registers it and posts the resulting PushSubscription to
me.push.subscribe. Quarry stores nothing; it forwards the subscription to
notify as subscriber credentials (PUT /v1/subscribers/{id}/credentials,
provider webpush), which is where every other channel's credentials live.
Delivery is notify's webpush provider (§12, issue 3): VAPID keys in notify's
environment, RFC 8291 encryption via webpush-go, a 404/410 from the
push service deleting the credential. Quarry never holds a VAPID key.
Routing is by workflow, defined in quarry (§7) and run by notify:
| workflow | steps |
|---|---|
quarry-mention |
in_app → push (immediate) |
quarry-forum-reply |
in_app → push (immediate) |
quarry-landing (opened/accepted/rejected, addressed to opener/reviewers) |
in_app → push |
quarry-activity |
digest 15 min → in_app; email optional per preference |
quarry-error, quarry-slow |
in_app → push, to admins (unchanged) |
Preferences (me.prefs) are notify preferences surfaced in quarry: per
workflow family × channel (in-app, push, email), plus a per-repo mute that
quarry implements by not triggering.
7. Provisioning is quarry's job
Today docs/running.md asks an operator to create subscribers, topics, topic
members and workflows by hand, and nothing in Go ever does. That is the gap
that makes every trigger today reach nobody. Quarry owns the mapping and so
must own the state:
account.add/ first login →EnsureSubscriber(account);account.grant/forum.subscribe/chat.read(first) →EnsureTopicMember(repo-<name>, account);account.revoke→ remove;- startup →
EnsureWorkflows(), definitions ininternal/notify/workflows.goas Go values, upserted (until notify#303 lands: GET, then POST if absent — never PUT over an operator's edits); - all best-effort, logged, retried on the next occasion; a notify outage never fails the op that raised it (RFC 0001 §5).
The notify.Client grows exactly those methods. It stays a thin edge.
8. Capabilities
C gates reading, posting, read-state and own-message edit/delete, as RFC
0004 already says for chat. Deleting another's message is a. Inbox,
preferences and push are p + login — they are about the person, not a
repository. Mention lookup is h, because it reveals handles. The bridge and
the robot are repository configuration, a. No new letters.
9. The Fossil chat bridge
Fossil's chat is a server-local table, never synced, gated by C, long-polled
through /chat-poll (blocks up to chat-poll-timeout, default 420 s),
written through /chat-send, with deletion markers, attachments, markdown,
and no mentions, edits, threads or read-state. Every key below was verified
against src/chat.c.
Per mirror, opt-in (mirror.chatbridge), stored in OLTP beside the mirror
row, never in the DAG:
- Inbound. One goroutine per bridged mirror:
GET /chat-poll?name=<last msgid>&raw=1with a client timeout above the hang; eachmsgs[]entry becomes aMessage{Kind: msg, Origin: fossil, User: "fossil:"+xfrom}onchat.<code>; anmdelentry becomesKind: del. Attachments link to the upstream's/chat-download. Messages from the upstream'schat-timeline-userare skipped when the quarry robot is on for the channel, or every commit is announced twice. - Outbound. Quarry messages with
Origin == ""are posted to/chat-sendasmsg="**name:** text", since Fossil attributes everything to the bridge login. Loop guard: inbound messages from the bridge user are ignored. - What it does not pretend. Fossil messages cannot be edited from quarry; two Fossil servers have two chats; a bridged channel's history is still quarry's window. The bridge is a connector, in the spirit of the IRC bridge on Fossil's own forum, and the channel is whole without it.
10. The activity robot
Fossil's chat-timeline-user is its one good chat idea: the timeline talks in
the channel. Quarry does it on its own stream: chat.robot stores an event
pattern per repository (webhook pattern syntax, validated the same way), and
a consumer of QUARRYEVT writes Kind: robot lines — one compact line per
check-in, landing verdict, CI verdict — into the channel. Robot lines never
count as unread and never mention anyone.
11. Principle 3, and the one exception
Every page in this RFC renders its state on load and works through forms and links: chat history and posting, read-state (a "mark read" button), the inbox and its actions, preferences. Live delivery, toasts, the title count and the mention autocomplete are enhancements; without them the page is the load-time state plus refresh, which Principle 3 calls acceptable.
The exception is push. A service worker is JavaScript by definition, and there is no form that can receive a push. This RFC records that as an explicit, scoped exception: with JavaScript off, the push control in preferences says so and nothing else changes. It is the only feature in quarry that requires script, and it is opt-in per device.
12. Upstream
notify (filed 2026-08-29):
- notify#300 — mark seen / read / archive endpoints for in-app messages — fields exist
in
domain.Message, no route sets them; - notify#301 —
unreadCountand cursor pagination onGET /v1/messages, plusGET /v1/messages/count; - notify#302 — a
webpushprovider for thepushstep (VAPID, RFC 8291, credential pruning on 404/410); - notify#303 — idempotent
PUT /v1/workflows/{slug}upsert, no-op when unchanged; - notify#304 — README says dagnats is embedded; the runtime dials a sidecar — fix one.
Issues 1–2 gate the inbox half of Phase 3; issue 3 gates push; issue 4 is a convenience (§7 has the fallback). Phases 0–2 need nothing upstream.
dagnats: nothing expected. Digest, delay and idempotency already live where notify needs them; see RFC 0012 §Upstream for the verification.
13. Disposability (RFC 0001 §11), applied
QUARRYCHAT and QUARRYATTN are windows and were never truth; wiping them
loses the window, exactly as today. chat_read, mention, robot and bridge
settings are OLTP: small, authoritative, and rebuildable in the weak sense
that losing them loses convenience. Nothing here writes to the DAG except
chat.promote, which writes a forum thread through the ordinary write path
and is the point.
14. References, reply-quotes and previews
Decided 2026-08-29, one question at a time; recorded here so the choices read as choices.
Syntax — one resolver, every surface. ^412 is a chat message by
sequence in the current channel, and #412 is ticket 412 on every
surface, chat included; ticket #412 still names the ticket. Until
2026-10-01 a bare #412 in chat was the message (Fossil's own # =
msgid convention, which Fossil honours only in /chat-query); a chat
line posted before then still reads that way, edited or not, because
each line records the grammar it was written in (quarry ticket #309).
A message reference, ^412 or an old line's #412, renders as a chat
bubble and the number, as a ticket's renders its state and number. ^412
is text outside chat, in code, after a word (2^10) and in a
footnote's [^1]. [hash] and
bare hash prefixes resolve to check-ins, tickets, threads, landings, wiki
pages and technotes exactly as the markup resolver already does in
forum and ticket text; @alice is a person; a pasted quarry URL is a
reference to whatever it names. sqlite:^412 and sqlite:[a91f3c2e]
reach across repositories, resolving only when the reader can see the
target. The same syntax works in chat, forum posts, tickets and landings;
a chat reference in durable text is allowed and rendered as ephemeral.
Reply-quotes, one timeline. A message posted with ref (the Reply
control) renders a compact quote of the referenced
message — author and first line — above its own text; the quote previews
on hover and jumps on click; the original gains "3 replies →". The log
stays one scroll. Ref is in the data (§2), so a later "view as threads"
toggle is a rendering preference, not a migration.
Previews degrade to links. Without JavaScript every reference is a
plain link: ^412 to chat.get's page, a hash to the artifact page, a
person to their page. With JavaScript, hover (debounced ~300 ms) or
keyboard focus fetches /r/{repo}/ref.frag?ref=… — the HTML translation
of ref.resolve — into one shared popover element positioned at the
anchor; click still navigates. On touch, the first tap opens the preview,
whose title is the link. Escape and tapping outside dismiss; focus is
never trapped. Fragments are GETs with the ingest-cursor ETag (§9), so a
hovered reference costs one request per change of the underlying store.
Cards, per kind, glanceable. One to three lines and an "open →":
check-in — hash, branch, author, first comment line, CI or landing badge;
ticket — status, title, last change; landing — branch → target, state,
position in its stratum; thread — title, replies, last reply; wiki —
title, first sentence; chat — author, time, folded text, reply count, or
"aged out"; person — identicon, handle, capabilities on this repository,
last active, and no address (RFC 0004 keeps those behind e). Diffs are
never in a card.
Backlinks, window-bounded, labelled. A reference from chat to an artifact is recorded at post time:
CREATE TABLE IF NOT EXISTS chat_ref (
repo TEXT NOT NULL, target TEXT NOT NULL, -- artifact hash or thread root
seq INTEGER NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (repo, target, seq)
);
pruned with the channel window. An artifact page shows a "chat" row —
count, last time, link to the latest mention — beside the index's durable
MentionRef rows, visibly distinct from them. A link to an aged-out
message lands on chat.get's honest page. Promotion (§1) writes the
references it carries into the forum thread it creates, where they become
durable MentionRefs the ordinary way.
Drawbacks
- A second live stream on every page. It is one connection, and it is the price of a forge that can tap someone on the shoulder; but it makes h2 a deployment requirement for the full experience.
- A service worker. Sixty lines of script that Principle 3 would rather not exist. Scoped, opt-in, recorded.
- Quarry now writes to notify. Provisioning makes notify's subscriber and topic tables a projection of quarry's OLTP. That is the correct direction — quarry knows who — but it is a second place the same fact lives.
- Append-only edit/delete means a reader folds tombstones; a naive
consumer of
chat.streamsees deleted messages. The API documentskindandref, and the OpenAPI example shows the fold.
Rationale and alternatives
- WebSockets would give one bidirectional connection per tab. SSE is what
Datastar speaks, what
Last-Event-IDgives resume for free, and what Principle 6 lists first. Sends are ordinary POSTs; nothing here needs client-to-server streaming. - Zulip-style topics are the strongest chat-structure argument in the
reference set. Quarry already has a durable, threaded, synced conversation
surface — the forum — and a second threading model inside an ephemeral
channel would compete with it.
chat.promoteis the bridge between "talk" and "keep". Left open in Unresolved questions. - Web Push in quarry would work without notify and hold VAPID keys in a second place. RFC 0001 §5 says notify owns channels; the survey found the provider is a small addition there; the upstream-first rule applies.
- Putting chat in
QUARRYEVTwould give webhooks chat for free and break RFC 0005 §2.1's classification while flooding every*subscription.chat.mentionedis the only chat fact that is operational state. - Long-polling, Fossil's choice, survives CGI. Quarry is a process; SSE is strictly better here.
Prior art
- Fossil
chat.c,alerts.md,hooks.md: ephemeral local chat underC; alerts by noun;after-receivehooks. Quarry keeps the first two decisions and already exceeds the third. - Campfire (ONCE): server-rendered chat with streams as enhancement, Web Push through VAPID, PWA badge; ~3 700 lines. The existence proof for this RFC's shape.
- Zulip: topics; the unread model that separates "unread" from "for me".
- Linear inbox, GitHub notifications: one inbox, a reason on every row, triage rather than deletion, immediate-vs-digest split by kind.
- Datastar
fetch.ts:last-event-idresend;openWhenHiddensemantics.
Unresolved questions
- ~~Threads.~~ Resolved: reply-quotes in one timeline (§14); a threads view may follow as a rendering preference.
- Anonymous posting. This RFC removes it. Keep it behind
Cfor forges that grantCtonobody? - The attention stream on every page (h2 required) or on repository pages only?
- Read receipts / presence. Not proposed; "recently active" only. Revisit once the channel has people in it.
- Durable archive. RFC 0001 §5 defers it.
chat.promotecovers the need this RFC has seen; search over chat waits on the archive.
Future possibilities
Reactions as Kind: react messages (append-only fits them); a per-channel
search once an archive exists; chat.quarry as the fleet channel RFC 0001 §5
names; a bot capability letter so the robot and the bridge post as accounts
rather than origins; PWA install with an app badge, which the attention
stream's count already computes.