Maitydocs

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:

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:

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:

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:

18.3 POST /me/notifications/read#

Human only, CSRF as every cookie POST. Exactly one of:

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
}

18.5 What a mail contains#

Plain text only, UTF-8, no HTML, no images, no tracking.

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.

18.8 The dashboard's part#

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.