18. Notifications for humans#
The maintainer's decision of 2026-10-22: humans are told when something
waits for them, in three stages that build on each other — badges and a
bell in the dashboard, browser notifications, and mail. His decision of
2026-10-26 narrowed what counts as waiting: only what the human has to
react to (§18.1). Agents are not
part of this: they keep events and wait (§8.3). Every endpoint here is
for a human's cookie session; an agent credential is forbidden with
details.reason: humans_only (§2.3), a runner credential forbidden.
18.1 What a notification is#
A notification is something the human has to react to: someone trying
to reach them (chat, mention) or something waiting in their inbox
(inbox) — plus the security alerts that protect their account. It
is not an activity log. The maintainer's decision of 2026-10-26
narrowed the kinds to these four: what only informs — a change on a
card, a ticker item, a jour fixe step, an agent's outage as such —
is shown by the board, the ticker and the project's feed, and what
needs the human's action reaches them as a request in their inbox.
A notification is not stored content. It is a view the hub derives, when it is read or sent, from two sources, plus a read flag per human:
- Events the human may read (§8.2), and of them only four groups:
messageevents that address the human as a chat party or by mention,mentionedevents, the request eventsrequest_created,request_forwarded,request_answeredandrequest_withdrawn, and two jour fixe events:jour_fixe_reportedfor the series' report recipients andjour_fixe_skippedwithskip_reason: no_moderatorfor its keepers (§20.2, §20.9). No other event kind makes a notification, so the derive reads only these rows of its window, not every event of every project the human is in — that is the point of the narrowing for the hub as well. A notification never says more than its event lets the reader read: the hub checks the readable set at the moment of every read and of every mail, so a membership that ended, a chat the human left, an event that was redacted (§8.5) or a request that is no longer addressed to them drops out, and a redacted event shows asredacted: truewithout a title. - Account records — the
securityitems, theinboxitems of personal requests (below), and theinboxitemsregistration_pending,report_open,moderation_appliedandmail_disabled. An account record is an audit record the hub writes about a human's own account, not about a project, so it is not an event and §8 does not carry it. Its id isacr-<n>from a sequence of its own, never the event sequence (§8.1): a record takes noseq, so it leaves no gap in any project's/eventsfrom which an owner or admin, an admin agent included, could time the humans' sign-ins and logouts./eventsnever returns it, andPOST …/events/{seq}/redactcannot name it. It is readable by that human alone — no other member, no instance admin, no agent, no runner — and kept 90 days. The types:mail_disabled(§18.7) andemail_address_changed(§18.4); with the sign-in contract (#169, §2.6.5) alsosession_createdandsession_ended, shown in the account's session list;session_endedis no notification; and with the instances contract (#162, §7)personal_request_created,personal_request_answeredandpersonal_request_withdrawn; and with registration (§2.7.5, §16.4)registration_pending, written for every instance admin, andregistration_approved, written for the person, which is no notification: the person learns of the approval by mail and by signing in; and with abuse reports (§16.8)report_open, written for the instance admin, andmoderation_applied, written for the account an action concerns; and with forge access per owner (§11.4)forge_access_changed, asecurityitem for a new link or token and no notification otherwise; andagents_not_listening{instances}(§5.3), written in place of the digest request undernot_listening.mode: bell.
Personal requests (#162, §7) are requests the hub files to the
human who owns an agent profile (to: "profile_owner:<agent id>"),
or to the human whose preferences an agent proposes (to:
"human:<human id>", §19.4), whether or not that human is a member of
the request's project. They
reach the human through account records, never through an event's
audience: every state change of one — filed, answered, withdrawn —
writes one account record of the addressed human, {request_id,
project, org, status} and nothing of the request's content (org: {id, handle} for one that concerns an organisation, with
project: null when no project is concerned, §7, §6.26; else org:
null). The
personal_request_created record is the human's inbox item for it;
the other two only move the position (§18.2) and close the count. So a
personal request has the same id scheme, position and read flag as
every other record, member or not, and nobody but the addressed human
learns of it from this view. The request's own events (#162, §7: ids
and status only) never make an item, also not for an owner who is a
member and in their audience, so each personal request is one item.
What an owner used to learn from a notification of its own reaches
them this way (2026-10-26): an agent's outage (§15.7) and an
unconfirmed Keep the Why commitment (§6.23) are personal requests of
class info that the hub files and withdraws itself; so is the hourly
digest of agent instances that are not listening (§5.3), with project:
null, or, under not_listening.mode: bell (§18.4), the account record
agents_not_listening instead.
kind |
From | Who gets it | Title (the hub composes it; no body) |
|---|---|---|---|
inbox |
request_created addressed to the human (to resolves to them, as to=me in §7); request_forwarded that makes them the holder, or opens the request to trust:admin while they hold admin (§7, forwarding); request_answered and request_withdrawn of a request they asked; the account record personal_request_created of a personal request addressed to them (#162, §7), member of its project or not — among them the hub's own notices of an agent's outage (§15.7), of an unconfirmed Keep the Why commitment (§6.23) and the hourly digest of agent instances that are not listening (§5.3); the account record agents_not_listening that replaces that digest under not_listening.mode: bell (§18.4); the account record registration_pending {human, handle} (§16.4), written once per instance admin when a registration waits for approval; the account record report_open {report, reason, type} (§16.8), written for the instance admin for a new abuse report; the account record moderation_applied {report, action, type, note} (§16.8), written for the account a resolved report's action concerns; the account record mail_disabled, which the hub writes when it disables the human's address after hard failures (§18.7); jour_fixe_reported of a series the human receives the report of, and jour_fixe_skipped with skip_reason: no_moderator of a series the human keeps (§20.2, §20.9) |
the addressee; the asker; for registration_pending every instance admin and nobody else; for report_open the instance admin (§16.4) and nobody else; for moderation_applied the account the action concerns, never the reporter; for mail_disabled and agents_not_listening the human; for jour_fixe_reported each human report recipient of the series (§20.2) who may read the round (§20.7), for the no_moderator skip each human keeper of the series |
<asker> asks: <request title>, or the request title alone for one the hub files with asked_by: null (the digest, §5.3); <n> of your agent instances are not listening, linking to /agents, for agents_not_listening; <who> answered: <request title>; <who> withdrew: <request title>; <handle> waits for approval, linking to Admin → Registration; Report: <reason> — <type>, linking to the report in Admin → Reports; Moderation: <action> — <type>, then : <note> when there is one, linking to the account page; Mail to <address> disabled after 3 failures, linking to the notification settings; Jour fixe report: <topic>, linking to the round; Jour fixe skipped, nobody to moderate: <topic>, linking to the series' moderators |
chat |
message of type chat from the other party of a direct conversation (§9), or from another participant of a group chat (§9.2) |
the human party or participant; never an observer, never the writer | <writer> in <conversation title or agent handle> |
mention |
a message (types other than chat) whose audience names the human by mention; a mentioned event; a mention in a group chat (§9.2) |
the mentioned human, never for a self-mention | <writer> mentioned you in <card title / conversation> |
security |
the account records session_created (#169, §2.6.5) and email_address_changed (§18.4); second_factor_changed and second_factor_failed (§2.8); forge_access_changed with change linked or token_set (§11.4) |
the human | New sign-in: <browser> on <os> (<method>); Sign-in address changed; Two-factor sign-in turned on / turned off / New recovery codes / Recovery code used / Two-factor sign-in reset by <admin> / Two-factor sign-in turned off for this instance; Wrong second-factor codes after a sign-in: <browser> on <os>, <ip>; Forge access added: <account login> (<source>) |
The two jour fixe items are ordinary items: read by their read flag,
mailed as the inbox mail mode says (§18.4), listed for 30 days, and
dropped when the human can no longer read the round or the series
(the readable set, above) or leaves the report list (§20.2).
The registration_pending, report_open and mail_disabled items ask
for an action as a request does — approve or reject the registration
(§16.4), resolve the report (§16.8), set the address again (§18.7) —
and count in inbox until it is taken, whatever their read flag
(§18.2), and are listed while it is open, however old (§18.2). Each is
marked read on its own once the matter is closed: the registration
decided (approved, rejected or expired), the report resolved or
expired, the address set again and verified, or removed. Neither
registration_pending nor mail_disabled is ever mailed, whatever the
inbox mail mode says (§18.4, §18.5): a registration_pending mail
would let an anonymous registration trigger notification mail, which
§16.2 rules out, and a mail_disabled item has no working address to
go to. A report_open item is mailed by the inbox mode like any
other: an abuse report should reach an admin soon, also away from the
dashboard; only someone signed in can file one, within the report
limits, and the mail carries its reason and type, never its content.
A moderation_applied item is no open matter: nothing in it is the
account's to act on, since only an instance admin lifts a hold (§16.8).
It is an ordinary notice, read by a read mark (§18.3), and counts in no
inbox count. It is mailed by the inbox mode, except that a suspend
item goes at once whatever the mode says (§18.5): the suspended account
signs in no more, so the mail is its one way to learn of it.
No longer notifications (2026-10-26). The kinds card, ticker,
jour_fixe, keep_the_why, agent, system and registration are
gone; the events behind them are unchanged. Changes on a human's cards
and their watches (§6.4) are shown on the board and the card,
ticker items in the ticker (§17), a jour fixe in its round (§20.9);
an agent's outage and an unconfirmed Keep the Why commitment become
personal requests (above), a pending registration and a disabled
address inbox items (above), and registration_approved nothing.
security items are always listed and counted, whatever the browser
and mail settings say; they count in counts.security and never in
total.
A human's own actions never notify them. One event gives at most one
notification per human: when it qualifies under two kinds, inbox
wins over mention, mention over chat.
18.2 GET /me/notifications#
Human only. The counts for the badges and the newest items for the bell, across every project the human is a member of, plus the human's account records — personal requests (#162, §7) included — which need no membership.
| Query | Meaning |
|---|---|
limit |
0–50, default 20; 0 lists no item and answers the counts, by_project and next_after only (below) |
cursor |
§1.4, older items |
unread |
true: unread items only |
kind, project |
filters; comma lists. kind takes inbox, chat, mention, security; any other value is invalid with details.fields.kind: "unknown" |
after |
an opaque position (below), the next_after of an earlier answer passed back unchanged: only items past it, oldest first — for the browser notifications (§18.8) |
wait |
0–55 with after: hold the request until an item past after exists, as §8.3. A cookie session holds one such long-poll beside its one events long-poll; a second one supersedes the first (superseded: true) |
{
"counts": { "inbox": 3, "chat": 2, "mention": 1, "security": 0, "total": 6 },
"by_project": {
"oliver/maity": { "member": true, "inbox": 2, "chat": 2, "mention": 1, "total": 5 },
"oz/lasteverlm": { "member": false, "inbox": 1, "chat": 0, "mention": 0, "total": 1 }
},
"items": [
{ "id": "ntf-4237", "seq": 4237, "kind": "chat", "at": "2026-10-22T13:10:00Z",
"project": { "slug": "maity", "full_name": "oliver/maity", "name": "Maity" },
"actor": { "member_id": "agt-a", "display_name": "Agent A", "avatar_url": "/api/v1/avatars/agt-a?v=1c2d" },
"title": "Agent A in agent-a",
"target": { "type": "conversation", "id": "agt-a" },
"url": "https://maity.team/oliver/maity/chat/agt-a",
"read": false, "redacted": false },
{ "id": "ntf-4231", "seq": 4231, "kind": "inbox", "at": "2026-10-22T12:41:12Z",
"project": { "slug": "maity", "full_name": "oliver/maity", "name": "Maity" },
"actor": { "member_id": "agt-b", "display_name": "Agent B", "avatar_url": "/api/v1/avatars/agt-b?v=e4d9" },
"title": "Agent B asks: Which mail library for the hub?",
"target": { "type": "request", "id": "req-0012" },
"url": "https://maity.team/oliver/maity/inbox/req-0012",
"read": true, "redacted": false }
],
"next_cursor": null,
"next_after": "<opaque position>",
"server_time": "2026-10-22T13:10:05Z"
}
Positions. A place in the list is a position: an opaque
string the hub hands out as next_after and takes back as after and
up_to (§18.3). Clients pass it back unchanged; they never build,
parse or compare one, and never derive one from an item's seq or
record number. A position is valid only for the human it was issued
to.
What a position holds, for implementers: a vector of one component
per project the human is a member of — the project's id (never its
slug) and the highest seq of that project the position covers — and
one for the human's account records — the highest record number it
covers. An event lies behind the position when its seq is at or
below its own project's component, a record when its number is at or
below the account component; an item is past the position otherwise.
Sealed and bound. The hub seals every position with AES-256-GCM,
as §16.2 does (a random 96-bit nonce per seal stored with the
ciphertext), under the position key, with associated data the
human's account id and the purpose string notifications.position,
and a version inside, so the hub can tell a form it no longer issues
apart. Positions never depend on MAITY_SECRET_KEY being set:
- With
MAITY_SECRET_KEYset, the position key is derived from it (HKDF, infonotifications.position). - Without it, the hub creates a random 32-byte position key at first use and stores it in its own database. That is enough for what the seal protects against — a client forging or altering a position, or using another human's — since whoever can read the database can read every notification anyway.
- Setting, changing or removing
MAITY_SECRET_KEYchanges the position key and so invalidates every position; clients recover as below.
The holder of a
position learns nothing from it — no project, no number — and a
position opens only for the human it was issued to. The hub opens and
checks after or up_to before any work that depends on the human's
memberships. The position comes in two forms, equally opaque to
clients:
- Inline — the normal case: the sealed vector itself, base64url without padding.
- Handle — the overflow case, when the inline form would exceed the cap of §1.7 (a human in very many projects): the hub stores the vector and hands out a sealed handle to it, sealed and bound the same way. A stored vector is immutable — a new position is a new stored vector — and is kept at least 30 days after its last use.
The hub never omits a component, never resets a position to the head, and never fails a valid request because of a position's size. The rules that make a position safe to resume from:
- One snapshot. Each answer reads the events, the account records,
the read flags and the counts in one database transaction at
REPEATABLE READisolation (PostgreSQL, MySQL/MariaDB; a SQLite transaction is serializable), so items, counts andnext_afterdescribe one moment. - Numbers become visible in order per project and per account. The
hub allocates an event's
seqin the transaction that writes the row, under the lock on its project (lock_project, the project row) held until commit, and a record's number under a lock on the human's account row, held until commit. Both numbers come from the database, never from a hub process: from the database's sequence (or a locked counter row).atcomes from the database's clock in that transaction, raised to the previous row'satin the same project (for an event) or the same account (for a record) should that clock ever step back. So with several hub replicas writing at once, a project never shows aseqbefore every lowerseqof that project is visible, an account never shows a record number before every lower one of that account, andatnever decreases along one project's events or one account's records. There is no hub-wide lock and no global visibility order: theseqsequence is instance-wide (§8.1), and events of different projects may become visible out ofseqorder and out ofatorder. A transaction that emits in several projects (a runner revoked or removed, a leaked credential revoked, an avatar change,soul_changed, a profile deleted) takes each project's lock as any other write does. Since a position holds one component per project and one for the account, it never skips an item that commits later. - One order. Items are ordered by
at, then events before records, then by number — the same order in both directions. Within one project, and within the account's records, this is the order of the numbers, sinceatand number agree there. next_aftermoves forward component by component. Withafter, the answer lists the items past it, oldest first, up tolimit. Whenlimitcut the list, each component ofnext_afteris the highest number listed for that project (or for the account), or the component ofafterwhen nothing of it is listed. Sinceatand number agree within one project and within the account, the listed part of each is a prefix of that project's (or the account's) items pastafter, so nothing omitted is skipped. When nothing was cut,next_afteris the snapshot's head: per project its highest visibleseq, for the account its highest visible record number. No component is ever lower than inafter.- Membership changes. A project the human joined after
afterwas issued has no component in it; it starts at that project's position when the membership began (its highestseqthen), never earlier, so joining never replays a project's history. A component that is inafterbut lower than the position at which the current membership began (the human left and joined again) is raised to that position. A project the human has left drops out ofnext_after. The account component needs no such rule. - Positions the hub cannot read. An
afterorup_tothat does not open for this human — forged, altered, issued to another human, sealed under a position key the hub no longer has, of a version the hub no longer accepts, a handle whose stored vector has expired, or the two-part form<seq>.<record>the hub issued before this rule — isinvalidwithdetails.fields.after(ordetails.fields.up_to)"position". The answer is the same in every case and never names a project. Such a position is never read as the head, which would drop unseen items from the long-poll and mark them read throughup_to. The recovery differs by parameter: after: onlydetails.fields.after: "position"lets the client start again from the head: it reads?limit=0for a fresh position and long-polls from there. This abandons the browser notifications of the items between the old position and the new head — they are not shown as browser notifications. The counts and the bell refresh from that read, and no read flag changes.up_to(§18.3): the call marks nothing. The client does not retry it with a fresh position on its own; it refreshes the bell, and only a new action of the human marks again.- Without
after(newest first, older ones throughcursor),next_afteris the snapshot's head: everything not listed is older and reached throughcursor, and a long-poll fromnext_afterreceives only what came later. -
limit=0lists nothing and moves nothing:next_afterisafterwhen given, with the membership changes above applied, else the snapshot's head — the starting point for a long-poll. -
Counts.
inboxis the number of open requests addressed to the human across projects — the sum ofopen_requestsin/me(§5.1), which never contains a personal request (the project's list does not carry it), plus every open personal request addressed to the human (#162, §7), member of its project or not, each counted exactly once; it falls when a request is answered or withdrawn, not when its notification is read; for an instance admin it also counts everyregistration_pendingitem whose registration is not yet decided and everyreport_openitem whose report is still open, and for every human amail_disableditem while the address is disabled (§18.1), each until the matter is closed, not until it is read.chatis the sum ofunreadover the conversations the human takes part in: its direct conversations with its agents and the group chats it is a participant of (§9; group chats with #153), in every project of its memberships. A group chat the human only reads along in as an agent's owner counts nothing. It is the same number astotals.unreadofGET /me/conversations(§9.3), the hub computes both with one function.mentionandsecuritycount the unread items of that kind.totalisinbox + chat + mention— everything but the security alerts; the dashboard shows it in the tab title as(6) maityand nothing when it is0.securityshows on the bell only. The keys are exactly these five; the counts of the kinds removed on 2026-10-26 (§18.1) are not sent. by_project. The same counts per project, keyed by the project'sfull_name, for every project of the human with at least one non-zero count; computed by the hub over every unread item and open request, not over the listed items, so the dashboard never counts from the newest 50.inboxis that project'sopen_requestsplus the open personal requests of that project addressed to the human,chattheunreadof that project's conversations,totalthe per-projectinbox + chat + mention. Every entry carriesmember:truefor a project the human is a member of,falsefor one that appears only through a personal request — an agent's outage (§15.7), an unconfirmed Keep the Why commitment (§6.23) included — then onlyinboxis set, and the dashboard shows it on the personal inbox and the bell, not in the project switcher, since the human cannot open the project. The sum of the entries'inbox, plus the open personal requests withproject: null(the not-listening digest §5.3, a preference proposal §19.4, an organisation's invitation §6.26) and the openregistration_pending,report_open,mail_disabledandagents_not_listeningitems, which belong to no project, iscounts.inbox: no request is counted twice or left out.securitybelongs to no project and appears only incounts.- Items. Newest first, one per event or record, the last 30 days;
older ones count as read and are not listed — except
securityitems, which stay listed and unread until a session allowed to mark them reads them (§18.3) or their record expires after 90 days, andregistration_pending,report_open,mail_disabledandagents_not_listeningitems, which stay listed while their matter is open (§18.1), however old, and are read once it is closed. That age-based reading never counts as a marking: it grants no session the good standing of §18.3.idisntf-<seq>for an item from an event andntf-acr-<n>for one from an account record; account-record items carryrecord(acr-<n>) instead ofseq. A personal request's item hastarget: {type: "request", id},project: {slug, name, member}(nullfor one that concerns no project) and the URL of the personal inbox (/requests/<id>, §16.11), which reads it throughGET /requests/{id}(#162, §7), membership not required; itstitleis composed when read, from that request.titleis composed from fields the human may read, in the human's interface language (§6.2), at most 200 characters, text only (rendered as text,web/context/rendering.md); it never carries a request'ssummary, a card'sbody, a report, a checkpoint or a message body. Items carry noreason: it named a card relation, and cards make no notification (§18.1).targetis the object the item links to —request,task(a mention in a card's thread),conversation,project(a mention in a project message),registration(aregistration_pendingrecord: the waiting human's id, linking to Admin → Registration),agents(anagents_not_listeningrecord, linking to the human's agents,/agents),report(areport_openrecord: the report's id, linking to Admin → Reports),moderation(amoderation_appliedrecord: the report's id, which the account cannot open, linking to the account page), and for the other account recordssessionorsettings(mail_disabledincluded),projectnullon those (a personal request's record targets itsrequest, above) — andurlthe dashboard's address for it underMAITY_PUBLIC_URL; the dashboard owns its routes, the hub builds the URL from the target. - Read. An item is read when it was marked (§18.3) or, for
chatand amentionin a chat, when the conversation has been read past itsseq(§9) — one read mark, the chat's own; aregistration_pending,report_open,mail_disabledoragents_not_listeningitem only when its matter is closed (§18.1, §18.3). Amoderation_applieditem is read by a read mark, as any notice: it is no open matter, since nothing in it is the account's to act on — only an instance admin lifts a hold (§16.8).
18.3 POST /me/notifications/read#
Human only, CSRF as every cookie POST. Exactly one of:
{"ids": ["ntf-4237", "ntf-4231"]}— up to 200 ids; an id the human has no item for is ignored, not an error (the item may have dropped out, §18.1).{"up_to": "<position>", "kind"?: "mention", "project"?: "maity"}— every item not past that position (§18.2; for "mark all read" thenext_afterof the answer the bell shows, passed back unchanged), optionally of one kind or one project. A position the hub cannot open isinvalidwithdetails.fields.up_to: "position"and marks nothing; the dashboard does not retry it with a fresh position on its own, it refreshes the bell, and only a new "mark all read" by the human marks again (§18.2).
Marking a chat item read moves that conversation's read mark to the
item's seq, never backwards — the same as reading the conversation
(§9); an observer has no chat items and moves no mark. inbox items
other than the four open matters below are marked read, and the open
requests stay open (§18.2). A kind
that is not one of them is invalid as in §18.2. A security item about a
new session (session_created, #169 §2.6.5) is marked read only from a
session that existed before that sign-in and is itself in good
standing (signed in by password, or its own session_created item
already marked read by an allowed session — never read by age, §18.2)
— never from the session it reports on and never from a
session created after it. email_address_changed follows the same rule, with the change in place of the sign-in. Security items (session_created and every
other security kind) are marked read only through ids, never through
up_to: "mark all read" skips them, so tidying the bell never confirms
a sign-in or grants good standing. From a session that may not mark
such an item it is skipped through ids too (ignored as an unknown
id), and the rest of the call applies. Answers 200 with the counts
of §18.2. No event: reading is not news to anyone.
The inbox items of an open matter, registration_pending,
report_open, mail_disabled and agents_not_listening (§18.1), are
never marked read by a caller: up_to skips them and an ids entry
naming one is ignored, as an id the human has no item for; each reads as
read on its own once its matter is closed — the registration decided or
expired, the report resolved or expired (§16.8), the address set again
and verified, or removed (§18.7), no instance of the human's away any
more (§5.3). So "mark all read" never hides an open matter from "Waiting
for you" while it still counts in counts.inbox.
18.4 GET, PATCH /me/notification-settings#
Human only. The settings belong to the account and follow it to every device; the browser's own permission is per browser and is not stored.
{
"kinds": ["inbox", "chat", "mention", "security"],
"not_listening": { "mode": "inbox", "minutes": 60 },
"browser": {
"enabled": true,
"kinds": { "inbox": true, "chat": true, "mention": true, "security": true }
},
"email": {
"address": "maintainer@example.org",
"address_status": "verified",
"verified_at": "2026-10-22T09:12:00Z",
"pending_address": null,
"language": "en",
"timezone": "Europe/Vienna",
"kinds": {
"inbox": { "mode": "immediate" },
"chat": { "mode": "daily", "content": false },
"mention": { "mode": "daily", "content": false },
"security": { "mode": "immediate" }
},
"sign_in_link": true,
"daily_at": "08:00",
"weekly_on": "mon",
"quiet_hours": { "from": "22:00", "to": "07:00" },
"hard_failures": 0,
"disabled_at": null
},
"mail_available": true
}
browser.enabledis the account's wish; the dashboard asks the browser's permission once, when the human turns it on, and shows the browser's answer beside the switch.kindsper kind on or off, every kind on by default. A notification carries the title only, never a message's text. APATCHthat names any other key inbrowser, the formercontentincluded, isinvalidatbrowser.<key>and changes nothing.securityhere governs only the browser pop-up; the item is always in the bell (§18.1).- The kinds.
kindsat the top,browser.kindsandemail.kindsname exactlyinbox,chat,mentionandsecurity(§18.1). APATCHthat names any other kind inbrowser.kindsoremail.kindsisinvalidwithdetails.fields["browser.kinds.<kind>"](oremail.kinds.<kind>)"unknown", and changes nothing. A value for a kind that is not what the field takes (abrowser.kindsvalue that is not a boolean) is"invalid"at the same field. not_listening. How the human hears that agent instances they own are not listening (§5.3, "The owner's digest"):modeinbox(default),belloroff;minutes10–1440 (default 60), how long an instance's episode lasts before it counts as away, the personal default that a project's own setting may override (§6.2). APATCHmay name either field. Any other value isinvalidwithdetails.fields["not_listening.mode"]or["not_listening.minutes"]"invalid".email.address. Setting it (PATCH {"email": {"address": "…"}}) puts it inpending_addressand sends a verification mail with a link (§18.6), valid for 24 hours; the verified address inaddresskeeps receiving until the new one is verified. The verification mail carries the address, the instance's public URL, the link and "If you did not ask for this, ignore this mail" — never the human's handle, display name or projects, since it goes to an address nobody has confirmed yet.- Address syntax. An address — here, in
from,reply_to(§16.2) and the test send'sto(§16.3) — is exactly oneaddr-specof RFC 5321:local-part@domain, ASCII only (no SMTPUTF8 in V1), at most 254 characters with a local part of at most 64, a domain of host-name labels with at least one dot (no IP literal). No display name, no angle brackets, no comma, semicolon, whitespace, CR, LF or other control character, no second@outside a quoted local part. Anything else isinvalidwithdetails.fields: {"email.address": "invalid"}(or the field's own name). The hub hands the checked address to SMTP as the single recipient; it never parses a list out of it. - One account per address. A verified address belongs to at most one
account, across registration (§2.7) and this section alike: a unique
constraint on the accounts' normalized verified address (trimmed,
lower-cased, as §2.6.3 compares), written under a per-address lock that
the verification here, an address change and the completion of a
registration (§2.7.1) all take. Exactly one of them wins an address.
A verification link for an address that another account verified
meanwhile is the page that says the link no longer works (§18.6,
never why), and it changes nothing; a pending address that another
account claims first stays unverified. Setting a
pending_addressis not a claim and is never refused for this reason, so the call says nothing about other accounts. - Changing a verified address. Once a verified address exists,
setting another one or removing it (
null) needs the human's own confirmation; how young a session is or how it was made does not count. A human with a password puts it in the same request,current_passwordbesideemail(missing:invalidwithdetails.fields.current_password: "required"; wrong:"mismatch", counted against the login limit of §2.1). A human without a password needs the change approved from a session of theirs in good standing (#169 §2.6.5) and at least 24 hours old: aPATCHfrom such a session is the approval; from a younger one the change is held (202,email.address_change: {id, address, requested_at, session_id},idan immutableadc-<random>) untilPOST /me/notification-settings/email/address-change/approve{id, address}from a session in good standing at least 24 hours old (younger:forbidden,details.reason: session_too_new) or…/address-change/reject{id, address}from a session in good standing. A session not in good standing can neither approve, reject nor replace a held change (forbidden,details.reason: not_in_good_standing); it may file one only while none is held. One change is held per human: a newerPATCHof the address from a session in good standing replaces it atomically under a newid, and an approval or rejection that names the oldid, or an address that is not the held change's, isconflict(§1.3) and changes nothing. A held change lapses after 7 days. When the new address is verified, or the address is removed, the old address gets one security mail saying the address was changed (the new one masked to its first character and its domain), when, and from which browser, operating system and IP address — whateveremail.kinds.security.modesays, since that mode governs only alerts of new sign-ins ("At once, whatever the mode", below) — and the hub writes the account recordemail_address_changed(asecuritynotification, §18.1). With the sign-in contract (#169, §2.6.3) the verified address is a sign-in factor. What this rule guarantees: neither a session obtained through the mailbox nor one approved by device code on an unlocked dashboard can move the sign-in path to another mailbox without the password. For a human without a password the guard is an age gate plus good standing: only a session in good standing and at least 24 hours old approves the change, so a session obtained a moment ago cannot, and one that waits a day still cannot while its own sign-in alert is unread (#169 §2.6.5); nor can it reject or replace a change the human filed.address_status:none,pending(an address, never verified),verified,disabled(§18.7). Setting the address again, also the same one, sends a new link and voids the old one.nullremoves the address and stops all mail.mail_not_configuredwhen the instance has no complete mail setting (§16.2);mail_availablesays so beforehand, and the dashboard hides the mail part when it isfalse. The address is the human's; no endpoint shows it to anyone else (§16.1's admin list does not carry it). POST /me/notification-settings/email/verify{"token"}— the same as the link's confirmation (§18.6), for a dashboard that receives the token from the link while logged in. The token must belong to the caller's own account (its human id is the caller's): a token of another human isnot_found, the same as an invalid one, and confirms nothing.email.kinds.<kind>.mode. The mode is the interval, chosen per kind:immediate(sent within about two minutes, skipped if the item was read in the dashboard first),hourly(one mail at the full hour with every unread item of the kinds set tohourly),daily(one mail atdaily_atintimezone),weekly(one mail atdaily_atonweekly_onintimezone), oroff. Every kind takes every mode. The defaults, by the maintainer's decision of 2026-11-02 against mailboxes filled by mention mails:inboxandsecurityimmediate;mention,chatand every kind added laterdaily. Theinboxmode never covers aregistration_pendingormail_disableditem: neither is mailed (§18.1). What thesecuritymode governs is the alert of a new sign-in (session_created, §2.6.5): animmediateone is not held by quiet hours, one the human put in a digest goes out with that digest,offsends none. The othersecurityitems are dashboard-only (forge_access_changed,email_address_changed) or go out at once (below).- At once, whatever the mode. These mails warn of a takeover under
way or answer something the human did, so no mode, digest, quiet
hours or
offholds them: the notice to the old address after an address change (above); the mail ofsecond_factor_changed(disabled, reset or changed, §2.8) and ofsecond_factor_failed(§2.8); the confirmation of an account deletion (§2.9); the mail after a registration decision (§2.7.5). They are security mail (§16.2, own budget). - Defaults and what was saved. The hub stores only what the human
set; a kind whose
modethe human never set follows the default as it is when the mail is built, so a changed default reaches every such kind of every account. A mode the human set — byPATCHor by an unsubscribe link (§18.6) — stays as set. TheGETshows the merged values, so a client sends inemail.kindsonly the kinds the human changed; a kind sent back unchanged counts as set. - Changing a mode. Items already waiting are mailed by the mode in
force when a pass builds the mail: switched to
immediatethey go with the next pass, switched tooffthey are not mailed while it isoff(the bell keeps them), switched to another digest they wait for its moment. A digest's moment is the latestdaily_at(onweekly_on, forweekly) not after the pass, so a changedweekly_onordaily_attakes effect at the next such moment, and a moment the hub missed (it was down) is sent at its next pass. weekly_on(defaultmon): the day of aweeklydigest, one ofmon,tue,wed,thu,fri,sat,sun; anything else isinvalidatemail.weekly_on. Aweeklydigest holds every item of the kinds set toweeklythat is still unread when it is built.- Not notifications. The sign-in link and code (§2.6.3), the second
factor's code (§2.8.8), a password reset and the verification of an
address (above) answer a request of the human: no mode, digest, quiet
hours or unsubscribe applies to them, and no setting turns them off
but
sign_in_linkfor the sign-in mail. sign_in_link(defaulttrue): whether the human may be sent a sign-in link and code by mail (#169, §2.6.3). Turning it off voids an open link and code. It has no effect until the sign-in contract is in.contentonchatandmention(defaultfalse): withtruethe mail carries the message text for this kind; without it the mail never contains a chat body (§18.5).quiet_hours.null, orfrom/toasHH:MMintimezone(crossing midnight allowed). Nothing is sent during them; what fell due is sent as one mail when they end.language. A tag ofGET /languages(§6.2, defaulten); the human's language for what agents write to the human personally (§6.2,docs/agents.md, "Rules"). The mails and the link pages (§18.6) follow the interface language instead (§6.2). It is the one place the human sets it; agents read it, without access to these settings, aslanguagein the human's member view (§6.2) and in the resume packet'speers(§5.2). It sits underemailbut does not depend on mail: it is stored and shown without an address and on an instance without mail (mail_available: false), and the dashboard keeps it visible when it hides the mail part.timezoneis an IANA name; the dashboard proposes the browser's.PATCHtakes any subset ofnot_listening(mode,minutes),browserandemail(address,language,timezone,kinds,daily_at,weekly_on,quiet_hours,sign_in_link), andcurrent_passwordbesideemailwhen the address changes (above; never stored, never echoed, ignored otherwise); the read-only fields (address_status,verified_at,pending_address,hard_failures,disabled_at,mail_available,kindsat the top) in a body areinvalidwithread_only. Answers theGETshape.
18.5 What a mail contains#
Plain text only, UTF-8, no HTML, no images, no tracking.
- Subject:
[Maity] <project>: <title>for one item;[Maity] 7 updatesfor a digest. Cut at 120 characters (with…) before encoding. - Header values. Every header value the hub builds — subject,
To,Fromand its display name,Reply-To— has every control character removed first: CR, LF, every other C0 character (U+0000–U+001F), U+007F and U+2028/U+2029. A value that is not pure ASCII is then encoded as RFC 2047 encoded-words (UTF-8,BorQ). Addresses are the checked single addresses of §18.4; the hub never joins strings into a header line or a recipient list by hand. - Body: per item its title, the project, the time in the human's
timezone and its
url; a digest lists up to 50 items, grouped by project, then "and N more" with a link to the dashboard. Then one line per kind in the mail: "Stop these mails:", and the link to the settings. - Only the hub's own links. The only links in a mail are those the
hub builds from
MAITY_PUBLIC_URL: the items'url, the settings, the unsubscribe links. Every text the hub did not write — titles, project names, message text, written by agents or humans — is quoted as plain text and never becomes a link: each piece is set off on its own as quoted text, an agent's marked as such (agent-b (agent) wrote: "…"), and anything a mail client could turn into a link — a URL scheme, awww.prefix, an address with@, a dotted host name — is defanged by writing its:after a scheme as[:]and its dots as[.]. The mail ends with "Maity only links to; a link anywhere else in this mail was not sent by Maity". So an agent cannot make the hub's trusted sender carry its link. - Never in a mail: a credential or any part of one, a join or
pairing code or link, a request's
summary,triedor options, a card'sbody, a report, a checkpoint, a briefing, the rules text, and a message body — except achatormentionmessage text whencontentistruefor that kind, cut at 500 characters. A redacted event never reaches a mail; if it is redacted after the mail was sent, the mail is beyond reach and the redaction cannot follow it. - Headers:
List-Unsubscribe: <https://…/mail/unsubscribe/{token}>andList-Unsubscribe-Post: List-Unsubscribe=One-Click(RFC 8058), for the mail's kind (a digest of several kinds: the first kind's link; every kind's link in the body);Auto-Submitted: auto-generated. - Rate — batched per human. Notification mail is built per human,
never per item: items that fall due for one human within the same
two-minute window go into one mail, and at most 10 immediate mails
per hour per human leave; past that the items of the hour go out as
one mail at the hour's end. When the instance's
max_per_hour(§16.2) is reached, what waits is merged into each human's next mail, one per human. Animmediatesecurity mail has its own budget and is never batched (§16.2);securityitems the human put in a digest travel in that digest and count as notification mail. - Never mailed: a
registration_pendingormail_disableditem, whatever theinboxmode (§18.1); the dashboard shows them. Areport_openitem is mailed by theinboxmode like any other (§18.1, §16.8). - Mailed whatever the mode: a
moderation_applieditem of asuspendgoes out as an immediate item even when theinboxmode ishourly,daily,weeklyoroff, while the address is verified and not disabled; quiet hours and the rate above still apply. The suspended account signs in no more and reads no inbox (§16.8, §18.1). Its other actions follow theinboxmode. - Freshness. Every item is checked against the readable set and its read flag when the mail is built, not when the event happened (§18.1). A digest with nothing left is not sent.
18.6 Links in mails#
Pages outside the API, on the public host only (a join host answers
them with its 302, §1.1), unauthenticated — the token is the
authority — with no cookie set or read:
| Path | GET |
POST |
|---|---|---|
/mail/verify/{token} |
a page naming the address and a "Confirm" button | confirms the address; the page says so |
/mail/unsubscribe/{token} |
a page naming the kind and a "Stop these mails" button | sets that kind's mode to off; also the RFC 8058 one-click POST with body List-Unsubscribe=One-Click |
/mail/second-factor-reset/{token} |
a page naming the account and the admin who asked, with the reason as quoted text, and a "Reset my two-factor sign-in" button | resets it (§2.8.3); the page says so and that every session has ended |
/mail/account-deletion/{token} |
a page naming the account, saying what deletion removes and keeps (§2.9), with a link to the export and a "Delete my account" button | deletes the account (§2.9); the page says so, or why it did not (namespace_owner, last_instance_admin) |
A GET never changes anything: mail scanners open links before the
human does. A token is the base64url of {human id, purpose, kind or
address, epoch} plus an HMAC-SHA-256 over it with
MAITY_MAIL_SIGNING_KEY; the hub stores no unsubscribe token. A verify
token and a second-factor reset token also carry their expiry (24
hours) and are single-use, and so does an account_deletion token,
which is also bound to the pending request and dead once that is
withdrawn or replaced (§2.9); a reset token also names the factor's
generation and is dead once that moves (§2.8.1, §2.8.3); an
unsubscribe token stays valid until the human's epoch moves, which
happens when the address changes or is removed. An unsubscribe token
for a kind this hub does not know counts as invalid. An invalid, expired
or used token is a page that says the link no longer works, with
not_found as status, never which of the reasons applies. The pages
are rendered in the human's interface language (§6.2), carry the headers of §1.1, and
the request log masks the token as ….
18.7 Delivery failures#
Minimal by design: the hub reads the SMTP answer of its own sending and nothing else; it reads no mailbox and processes no bounce or complaint mail.
- A temporary failure (
4xx, or no connection) is retried with backoff for up to 24 hours, then dropped; it counts as nothing. - A permanent failure at
RCPT TOorDATA(5xx) is a hard failure of the address. After 3 hard failures in a row (a delivered mail resets the count) the address goes toaddress_status: disabled, no further mail is sent to it, and the hub writes the account recordmail_disabled, which the human sees as aninboxitem in the dashboard (§18.1) — never by mail, since the address is disabled. It counts ininboxuntil the address is set again (§18.4) and verified, which enables it, or removed; then it is marked read on its own. - A failure of the instance's own setting (connection, TLS,
AUTH) counts against no human: it is a retry, and the admin'slast_testandsent_last_hourshow it (§16.2). - Complaints are handled by the unsubscribe link in every mail and in its headers (§18.6).
18.8 The dashboard's part#
- Stage 1 — badges and the bell. The dashboard reads
GET /me/notifications?limit=0for the counts (instead of summing/me) and shows them on the inbox, the chat list and the bell; the tab title is(<total>) maity. The bell opens the latest items (limit=20), each linking to itsurl, unread ones marked, with "mark all read". - Stage 2 — browser notifications. While a dashboard tab is open,
also in the background, it long-polls
GET /me/notifications?after=<position>&wait=55,<position>being thenext_afterof the previous answer as an opaque string (§18.2). Oninvalidwithdetails.fields.after: "position", and only on that error, it reads?limit=0and long-polls from the fresh position: the items between the old position and the new head get no browser notification, the counts and the bell refresh, read flags stay as they are. It shows each new item of a kind the human switched on through the Notification API, with the item'sidastagso that two open tabs show it once; a click focuses the tab and opens theurl. There is no push without an open tab: Web Push needs a service worker and a third-party push service, which this stage does not add. - Stage 3 — mail. The settings page edits §18.4; the admin area of the instance admin edits §16.2 and offers the test send (§16.3).
- "Waiting for you" is where everything meets. The all-projects
start page (and a project's own view, filtered to it with
project) is built from the same two sources as the bell, not from the inbox alone, and shows every unread item of every kind in §18.1: - Requests first, because they ask for an answer: the open
requests addressed to the human (§7), each as one row until it is
answered or withdrawn. An unread
inboxitem about such a request (request_created) is folded into that row, never shown twice; the otherinboxitems — an answer to or a withdrawal of a request the human asked — are rows of their own in this group, read like any item. A pending registration (for an instance admin) and a disabled address are rows of their own here too, until the matter is closed (§18.1). The hub's notices about an agent's outage (§15.7), an unconfirmed Keep the Why commitment (§6.23) and an agent that is not listening (§5.3) are personal requests and appear as such. - Conversations:
chatitems as one row per conversation or group-chat thread, with its unread count and the newest item's title (§18.1 composes titles; a message's text never appears here). - Mentions, newest first.
- Account: the
securityitems.
Changes on the human's cards, ticker items and jour fixe steps are not here (§18.1): the board, the ticker and the project's feed show them.
Each row links to its url; opening a conversation marks its items
read (§18.3). The page loads GET /me/notifications?unread=true,
paging with cursor until no unread item is left (at most 200 rows
shown, then a "more" link to the bell's full list), and then follows
the same single notifications long-poll that stage 2 holds for the
tab: it never opens a second one, since a second supersedes the first
(§18.2). A kind the human switched off in §18.4 still shows here; the
switches govern browser notifications and mail, not this page.
Without the notifications endpoint (a hub that has not reached stage
1) the page shows the open requests alone, as today, and says nothing
about the rest.
- Per-project badges come from by_project (§18.2), never from
counting listed items.