Maitydocs

16. Instance administration#

The hub has one instance admin role above every project: the person who runs the instance, not a member of anything. It is the scope instance:admin (§2.3), held by a human account and by nothing else.

Who holds it today. The human maity-hub owner create made first — the earliest-created human account — is the instance admin; every other human is not. GET /me says which (member.instance_admin, §5.1). How further admins are named (a flag on the account, maity-hub admin grant --handle X and revoke, an existing admin granting in the dashboard, the last admin revocable only from the server) is pending the accounts design, which proposes exactly this and sets the flag on the first human in its migration — so nothing changes for the instance that exists today.

What it is not. Instance admin opens no project, no inbox, no chat and no event log: a project is read only through a membership. The admin endpoints answer metadata about accounts, never content. The one exception is an abuse report's snapshot (§16.8): exactly what a member chose to report, and nothing around it.

Instance settings that change what the hub does, set in its environment by the operator: MAITY_ON_RUNNER_REVOKE — keep (default), stop or stop_floor, what revoking or removing a runner does to its agents in projects whose policy says inherit (§15.3, §6.1). MAITY_ATTACHMENT_QUOTA — bytes of attachments one project may store, default 1 GiB (§8.7); MAITY_ATTACHMENT_STORE and the MAITY_ATTACHMENT_DIR / MAITY_ATTACHMENT_S3_* settings — where the attachment bytes live (§8.7). The others are named where they act (MAITY_PUBLIC_URL, MAITY_JOIN_URL §1.1, MAITY_TRUSTED_PROXIES, MAITY_ALLOW_PLAIN_HTTP §2.5, MAITY_CLI_INSTALL §3.2, MAITY_KTW_SKILL_SOURCE and MAITY_KTW_SKILL_GUIDE §6.23, MAITY_SMTP_*, MAITY_MAIL_SIGNING_KEY and MAITY_SECRET_KEY §16.2).

Sign-in methods — device codes and sign-in links by mail — are switched by the instance admin or the environment (MAITY_SIGN_IN_*, §2.6.6); the password cannot be switched off. The second factor — off, optional, required_admins or email — is set there too (MAITY_SIGN_IN_SECOND_FACTOR, §2.8.5).

Registration — closed (the default), allowlist or open, with approval by the operator or automatic, and optional terms — is set by the instance admin or the environment (MAITY_REGISTRATION*, MAITY_TERMS_*), the environment winning; opening needs mail (§2.7, §16.4).

16.1 GET /admin/users#

Scope instance:admin, human only — a cookie session (§2.1); an agent or runner credential is forbidden. The humans of the instance, paged (§1.4), ordered by handle.

Query Meaning
q 1–64 characters; case-insensitive substring of handle, an alias (§6.24) or display_name; otherwise invalid
disabled true or false; omitted: both
account_status active or pending (§2.7.5); omitted: both
limit, cursor §1.4
{
  "items": [
    { "id": "hum-maintainer", "handle": "maintainer", "display_name": "Maintainer",
      "avatar_url": "/api/v1/avatars/hum-maintainer?v=5c81e0a7",
      "instance_admin": true, "disabled": false, "account_status": "active",
      "created_at": "2026-10-21T07:45:00Z", "last_login_at": "2026-10-22T08:02:11Z",
      "last_seen_at": "2026-10-22T11:40:00Z",
      "memberships": 2, "agents": 7 }
  ],
  "next_cursor": null
}

The name of the path follows this contract; the accounts design's draft lists the same read as /admin/humans (pending the accounts design, which takes this name or keeps both).

16.2 Mail settings#

The hub sends mail for three things only: the notifications of §18, the verification of a human's address (§18.4), and security mails — the security notifications of §18.1, the notice to an old address (§18.4) and, with the sign-in contract (#169, §2.6.3), sign-in links and codes; with registration (§2.7) also the registration mails. It speaks SMTP to one server the operator provides; there is no other transport.

Four budgets. Each kind of mail is counted on its own, and none can use up another:

So event churn by any member, an agent included, can delay notifications only, and anonymous sign-in or registration requests can stop only their own kind of mail; neither delays an alert, a verification or the notice to an old address.

Where each value lives. Every value may come from the environment or be set by the instance admin through PATCH; a value set in the environment wins, is shown with source: "env", and a PATCH of it is invalid with details.fields.<field>: "set_by_environment".

The password has the same two sources, with rules of its own:

Environment Field Meaning
MAITY_SMTP_HOST host the SMTP server's host name
MAITY_SMTP_PORT port default 587 for starttls, 465 for implicit, 25 for none
MAITY_SMTP_TLS tls starttls (default), implicit, or none — none only for a host that is a loopback address or the name localhost (a relay on the same machine or in the same pod); for any other host it is invalid and stops the hub at startup when set in the environment. Under starttls, STARTTLS is required: a server that does not offer it (or a path that strips it) fails the send at stage tls, loudly — logged, counted in failed_last_hour, and the test send says so — and nothing is sent in clear; there is no fallback. Certificates are always verified against the system store, or against MAITY_SMTP_CA_FILE when set; there is no switch to skip verification
MAITY_SMTP_ALLOW_PLAINTEXT — insecure, environment only, default false. With true, a starttls connection to a server that offers no STARTTLS sends the mail in clear instead of failing — every mail, sign-in links included, then crosses the network readable and changeable by anyone on the path. AUTH is still never sent without TLS (a configured username then fails at stage auth). While it is true the admin view carries warnings: ["smtp_plaintext_allowed"] and the hub logs a warning at startup and at every plaintext send. Meant for a test bench only
MAITY_SMTP_USERNAME username null: no AUTH. With a username, AUTH is sent only over TLS. Printable ASCII, as the password (invalid otherwise; in the environment the hub stops at startup)
MAITY_SMTP_PASSWORD or MAITY_SMTP_PASSWORD_FILE password write-only; {set, source} in the answer, source env or admin (above)
MAITY_SECRET_KEY — 32 bytes, base64; the instance key that encrypts secrets the hub stores (the admin-set SMTP password, the forge webhook secret and token, second-factor secrets). Never returned; {set} in the answer as secret_key
MAITY_SMTP_FROM from {address, name?}; the envelope sender and From:. In the environment maity@example.org or Maity <maity@example.org>
MAITY_SMTP_REPLY_TO reply_to null or an address
MAITY_MAIL_MAX_PER_HOUR max_per_hour notification mail, all humans together; default 500; over it, mail waits for the next hour and is merged into each human's next mail (§18.5). Security mail does not count here
MAITY_MAIL_SECURITY_MAX_PER_HOUR security_max_per_hour security mail (verification, immediate security notifications and the mails sent at once whatever the mode, §18.4), all humans together; default 100; neither notification nor sign-in mail counts here. Over it, security mail is queued for the next hour, never dropped
MAITY_MAIL_SIGN_IN_MAX_PER_HOUR sign_in_max_per_hour sign-in links and codes (#169, §2.6.3), all humans together; default 60. Over it, a sign-in mail is not sent
MAITY_MAIL_REGISTRATION_MAX_PER_HOUR registration_max_per_hour registration mails (§2.7.1), all addresses together; default 30. Over it, a registration mail is not sent
— enabled false stops every mail — notification, verification and security — without losing the settings; the test send still works
MAITY_MAIL_SIGNING_KEY — 32 bytes or more, base64; signs the links of §18.6. Without it the hub sends no mail (complete: false, missing: ["signing_key"]). Never returned

In the Helm chart the password and the signing key come from a Secret by reference, as the database URL does (mail.passwordSecret and mail.signingKeySecret, each with existingSecret and key, the keys defaulting to smtp-password and mail-signing-key), and the other values from mail.host, port, tls, username, from and replyTo; the secrets never pass through the chart's values. With a host set, port and TLS always come from the chart (its defaults included) and win over the admin settings (set_by_environment). The instance key comes from a Secret as well (secretKey.existingSecret and key, default secret-key). A release that moves these variables from extraEnv into mail.* and secretKey removes them from extraEnv in the same upgrade: the chart does not deduplicate, and the later entry wins.

GET /admin/settings/mail

Scope instance:admin, human only (§16.1):

{
  "enabled": true,
  "complete": true,
  "missing": [],
  "host":     { "value": "smtp.example.org", "source": "env" },
  "port":     { "value": 587, "source": "default" },
  "tls":      { "value": "starttls", "source": "default" },
  "username": { "value": "maity@example.org", "source": "env" },
  "password": { "set": true, "source": "env" },
  "from":     { "value": { "address": "maity@example.org", "name": "Maity" }, "source": "admin" },
  "reply_to": { "value": null, "source": "default" },
  "max_per_hour": { "value": 500, "source": "default" },
  "security_max_per_hour": { "value": 100, "source": "default" },
  "sign_in_max_per_hour": { "value": 60, "source": "default" },
  "registration_max_per_hour": { "value": 30, "source": "default" },
  "signing_key": { "set": true },
  "secret_key": { "set": false },
  "warnings": [],
  "last_test": { "at": "2026-10-22T14:02:11Z", "ok": true, "stage": null, "smtp_code": 250 },
  "sent_last_hour": 12,
  "security_sent_last_hour": 1,
  "security_queued": 0,
  "sign_in_sent_last_hour": 0,
  "sign_in_dropped_last_hour": 0,
  "registration_sent_last_hour": 0,
  "registration_dropped_last_hour": 0,
  "failed_last_hour": 0
}

source is env, admin (set through PATCH, stored in the database) or default. complete is true when host, from and the signing key are there and, with a username, a password; missing names what is not. password, signing_key and secret_key never carry a value, a length, a hash or a hint — only whether one is set. warnings lists smtp_password_in_database under the Kubernetes rule above, smtp_plaintext_allowed while MAITY_SMTP_ALLOW_PLAINTEXT is set, and smtp_password_unreadable while a stored password does not open with the current instance key (it then counts as not set; the hub also logs it at startup), else nothing. from.address and reply_to follow the address syntax of §18.4 (invalid otherwise); from.name follows the header rules of §18.5.

PATCH /admin/settings/mail

Scope instance:admin, human only. Body: any of enabled, host, port, tls, username, password, from, reply_to, max_per_hour, security_max_per_hour, sign_in_max_per_hour, registration_max_per_hour; null returns a field to its default (for password: deletes the stored one). password follows the rules above: set_by_environment when the environment sets it, secret_key_missing without the instance key. signing_key or secret_key in the body is invalid with environment_only, any other field with extra_forbidden. A tls of none with a host that is not a loopback address or localhost — whichever of the two the body changes — is invalid with loopback_host_only on the field the body changed. The body is applied whole or not at all. A value in the body is never echoed and never logged. Answers the GET shape. A change of host, port, tls, username, password or from clears last_test. The change is not an event (there is no project to put it in); the accounts design's instance audit log records it (pending).

16.3 Test send#

POST /admin/settings/mail/test   {"to": "admin@example.org"}

Scope instance:admin, human only. Sends one fixed plain-text mail ("Maity test mail from ", no other content) with the current settings, synchronously, with a 20-second limit. It connects only to the configured host and port — the body takes no host, port or TLS mode, and a field other than to is invalid — so the test send is no tool to reach another address from inside the hub's network. to is optional and defaults to the admin's own verified address (§18.4); without either it is invalid (details.fields.to: "no_verified_address"). It runs whether enabled is true or not, and is mail_not_configured when complete is false. Five per hour per instance admin (rate_limited).

{ "ok": false, "stage": "auth", "smtp_code": 535,
  "detail": "5.7.8 Authentication credentials invalid", "at": "2026-10-22T14:02:11Z" }

stage is null on success, or where it stopped: resolve, connect, tls, auth, mail_from, rcpt_to, data, timeout. detail is the server's reply line, cut at 200 characters, and only when it parses as an SMTP reply (^[2-5][0-9][0-9][ -]), else null; every occurrence of the username or the password, and of their base64 forms, is replaced by …; never a stack trace. to follows the address syntax of §18.4. The result is stored as last_test. A failed test is a 200 with ok: false: the call itself worked.

16.4 Registration#

The admin area's section "Registration": the mode, the two lists, approval, terms, and the accounts that wait for approval. The flow itself is §2.7.

GET, PATCH /admin/settings/registration

Scope instance:admin, human only (§16.1). Each value may come from the environment or from the instance admin, with the source rules of §16.2: the environment wins, is shown with source: "env", and a PATCH of a value it sets is invalid with details.fields.<field>: "set_by_environment".

Environment Field Default Meaning
MAITY_REGISTRATION mode closed closed, allowlist or open (§2.7)
MAITY_REGISTRATION_DOMAINS domains [] domain entries, @example.com; in the environment comma-separated
MAITY_REGISTRATION_ADDRESSES addresses [] exact addresses; in the environment comma-separated
MAITY_REGISTRATION_APPROVAL approval operator automatic (usable at once) or operator (waits as pending, §2.7.5)
MAITY_TERMS_URL, MAITY_TERMS_VERSION terms null {url, version}: an https URL (2048 characters) and a version (1–64 characters, no whitespace); both or neither; when neither is set and the legal pages hold terms, those (§16.7.1, source: "legal_pages")
MAITY_REGISTRATION_MAX_NEW_PER_HOUR max_new_accounts_per_hour 20 accounts completed per hour across the instance (§2.7.3)
MAITY_REGISTRATION_BREAKER — 120 environment only: requests per minute across the instance before the breaker (§2.7.3)
{ "mode": { "value": "allowlist", "source": "admin" },
  "effective_mode": "allowlist",
  "domains": { "value": ["@example.com"], "source": "admin" },
  "addresses": { "value": ["guest@example.net"], "source": "admin" },
  "approval": { "value": "operator", "source": "default" },
  "terms": { "value": { "url": "https://example.com/terms", "version": "2026-10" }, "source": "env" },
  "max_new_accounts_per_hour": { "value": 20, "source": "default" },
  "missing": [],
  "pending": 2,
  "registered_last_hour": 1, "requests_dropped_last_hour": 0 }

Accounts that wait.

Endpoint Effect
GET /admin/registrations the pending accounts, oldest first, paged (§1.4): {id, handle, display_name, address, created_at, expires_at, source_ip, terms}
POST /admin/registrations/{id}/approve pending → active; 200 with the item and account_status: "active"
POST /admin/registrations/{id}/reject {reason} deletes the pending account and its sessions; reason 1–1024 characters, required, mailed to the person; the address is blocked for 30 days; 200 {id, status: "rejected"}
DELETE /admin/registrations/blocks/{address} lifts a block early; idempotent

Scope instance:admin, human only, for all four. An id that is not a pending account is not_found; one decided meanwhile by another instance admin is invalid_transition (details.from: active, rejected, or expired for one deleted after 30 days, §2.7.5). The address is shown here — to the instance admin, for pending accounts only — because deciding needs it; §16.1 still carries none. reason follows the rules of a mail body (§18.5): plain text, no link is made from it.

Records. Registration belongs to no project, so none of this is an event of §8. The hub writes instance records for the accounts design's instance audit log (pending): account_registered {human, handle, approval, terms_version}, registration_approved {human, by}, registration_rejected {human, by, reason}, registration_expired {human} and registration_settings_changed {fields, by} (names only, no list contents in the record). Two account records (§18.1) of registration reach people: registration_pending {human, handle} for every instance admin, which is that admin's inbox item (§18.1), counted until the registration is decided and then marked read on its own, and registration_approved for the person, which is no notification.

16.5 Limits#

The admin area's section "Limits": the caps the operator puts on what one account may hold. Plans (§2.7, part two) will set these per plan; until then there is one value for every account.

GET, PATCH /admin/settings/limits

Scope instance:admin, human only (§16.1); environment and admin values with the source rules of §16.2, as in §16.4.

Environment Field Default Meaning
MAITY_MAX_AGENTS_PER_HUMAN max_agents_per_human 100 agent profiles one human may hold that are not deleted (§6.3): an integer 1–10000, or the string unlimited for no limit; in the environment the same spellings, an unset or empty variable means not set by the environment
MAITY_MAX_ORGS_PER_HUMAN max_orgs_per_human 5 organisations that are not deleted in which one human is an owner (§6.26): an integer 1–10000, or unlimited, with the same spellings and environment rules as max_agents_per_human
{ "max_agents_per_human": { "value": 100, "source": "default" },
  "humans_at_limit": 0,
  "max_orgs_per_human": { "value": 5, "source": "default" },
  "humans_at_org_limit": 0 }

null keeps the meaning of §16.2 here too: a PATCH with max_agents_per_human: null restores the default and the source default; "no limit" is the value unlimited, stored as the admin's value with source: "admin". humans_at_limit is read-only (a PATCH naming it is invalid, read_only) and 0 under unlimited; so is humans_at_org_limit for max_orgs_per_human, which takes null and unlimited the same way.

16.6 Instance statistics#

The admin area's section "Statistics": counts the operator reads to see whether the instance is used, computed from the tables the hub has anyway. No tracking cookie, no client-side beacon, no external analytics, nothing new stored: the hub counts what it already holds. The one exception is the daily sums per landing variant and campaign (§16.7.2), which the operator turns on by configuring either.

GET /admin/statistics?days=30

Scope instance:admin, human only (§16.1). days is 1–90, default 30.

{ "as_of": "2026-10-25T10:00:00Z", "window_days": 30,
  "totals": { "humans": 41, "humans_pending": 2, "agents": 117, "projects": 12,
              "instances_online": 23, "humans_with_an_agent": 29, "humans_deleted": 3 },
  "days": [
    { "date": "2026-10-25", "registrations": 3, "new_instances": 2,
      "humans_active": 17, "agents_active": 44,
      "cards_created": 31, "cards_claimed": 28, "cards_done": 19, "reviews": 12 }
  ],
  "returning": { "after_7_days": 11, "after_30_days": 4 },
  "coverage": { "reviews_since": "2026-10-22" } }

16.7 Landing page#

What a visitor who is not signed in sees at the instance's root. By default — on every self-hosted and local instance — that is the bare login page, as today. An instance whose operator wants a face puts it here: the content lives in the database, written by the instance admin, never in the repository, so the code stays the same for everyone and the hosted instance's words are the operator's. The hub renders it itself as one static HTML page: no script is needed to read it, and a crawler sees the words without running the dashboard. Its look — the template, colours, fonts, the hero's rings, the motion — is code, the same for every instance; a small first-party script improves the page (motion, the demo's typing, the theme switch) and is never needed to read or use it (progressive enhancement, below).

GET, PATCH /admin/settings/landing

Scope instance:admin, human only (§16.1). Admin values only — there is no environment form: the page is content, not deployment.

Field Default Meaning
enabled false whether the root shows the landing page to visitors without a session
default_language "en" the language a visitor gets when none of theirs is offered
pages {} per language (en, de, fr, es, pt, it, nl, ja, ko, zh-Hans), the page: {title, description, sections}; enabled requires a page in default_language. These are the languages of the public pages and the interface languages of §6.2; a human's language and a project's choose from the wider list of GET /languages. The language bar names each in its own language
variants {} at most 5 further pages served at /for/<slug>, each {pages} (§16.7.2)
campaigns [] the campaign codes the hub counts, at most 200 × {code, label} (§16.7.2)

title is 1–120 characters, the page's <title>; description at most 300 characters, its meta description. sections is an ordered list, at most 20 entries, each {type, enabled, …}; the page shows the enabled ones in that order. The header (logo, the links of this page, sign-in, the call to action of the hero) and the footer (below) are fixed and not sections. The header's links are the enabled sections that draw something on the page (not activity or collab, not profiles or explore in this version, not a stats section with no counter left to show) and carry nav_label, in their order: any section may have one (trimmed, 1–24 characters), at most 4 among the enabled sections of a page (a fifth is invalid, details.fields naming it, too_many; a disabled section's label counts for nothing), and a section without one has no link; the link points at the section's anchor (#s<index>, its 0-based position in sections, disabled sections included). A label on a section that draws nothing still counts toward the four. A section title is never a link text by itself, so a long title cannot break the header. The types and their fields:

A bare number below counts characters; a markdown field, note and a question's answer count UTF-8 bytes.

type Fields Shown
hero eyebrow (≤ 40), title (≤ 120), subline (≤ 300), cta_primary and cta_secondary ({label ≤ 40, href}, the second optional), note (Markdown ≤ 500) always
stats items: the counters to show, any of people, agents, public_projects, cards_done_week, people_online, agents_online, instances, instances_online always (below)
steps eyebrow (optional, ≤ 40), title (≤ 120), items: 1–6 × {title ≤ 80, text ≤ 300, code? ≤ 300} always
scales eyebrow (optional, ≤ 40), title (≤ 120), items: 1–4 × {tag ≤ 40, title ≤ 80, text ≤ 300, points: ≤ 5 × ≤ 120} always
faq eyebrow (optional, ≤ 40), title (≤ 120), items: 1–20 × {q ≤ 200, a: Markdown ≤ 2000} always, each item a <details> with the question as its <summary>
demo eyebrow (optional, ≤ 40), title (≤ 120), subline (optional, ≤ 300), terminal_lines: 1–8 × {kind, text ≤ 200} with kind one of cmd (a command typed), out (its output), note (a comment); inbox (optional, {from ≤ 80, question ≤ 300, context? ≤ 300}: the question an agent asks its human); steps: exactly 3 × {title ≤ 80, text ≤ 300} always; without script every line, the inbox item and the steps are shown at once; with script the lines are typed one after another, the inbox item appears after the fourth line (after the last when there are fewer), as the design's timeline has it, and the steps are shown at once
opensource eyebrow (optional, ≤ 40), title (≤ 120), markdown (optional, ≤ 600), tiles: 1–3 × {label? ≤ 40, title ≤ 80, text ≤ 300}, links: 0–4 × {label ≤ 40, href} always
cta_band title (≤ 120), text (optional, ≤ 300), cta ({label ≤ 40, href}) always; without text only the title and the button
works_with label (≤ 40, e.g. "Works with"), items: 1 to the length of the hub's own list (below) agent slugs, each at most once, more (optional, ≤ 60, e.g. "+ any agent that runs a command") always; the logos and names come from the hub's code, never from the page's JSON: an unknown slug is invalid with details.fields.<path>: "unknown_agent", a repeated one with "duplicate"
text eyebrow (optional, ≤ 40), title (optional, ≤ 120), markdown (≤ 16 KiB) always
activity title, count (1–20) not in this version, like collab: a public project's page shows no event (§16.9), so there is nothing yet this section may show; what it would show comes with its own contract change
profiles eyebrow (optional, ≤ 40), title (≤ 120), markdown (≤ 2000), handles: ≤ 2 handles of humans with a public profile (public_profile: true, §16.9) only while public pages are on; a handle without a public profile, and an agent's handle, is left out
explore title (≤ 120), projects: ≤ 3 project full names, or [] for the hub's own choice only while public pages are on; projects without a public page are left out
collab title, text, items: 2 × {tag, title, points, cta}, footnote not in this version: the hub keeps it and renders nothing until the feature it describes exists

The agents a works_with section names are a fixed list in the hub's code, each with its display name and logo: claude-code, codex, opencode, gemini, antigravity, copilot, cursor, pi, goose, cline, roo, kilo, amp, droid, windsurf, zed, kiro-cli, trae, openclaw (the maintainer's decision of 2026-10-31, the same set as Keep the Why's). The logos are third-party marks, one file per agent at /assets/landing/agents/<slug>.svg, answered like the other landing assets (below). They are not under the project's MIT licence: the folder's NOTICE names each mark's origin and the terms of its use, the repository's LICENSE and README point to that exception, and the NOTICE ships in every package with the files. The page's JSON cannot add, replace or point to any logo; a new agent is added to the list in the code. The section draws the label and a band of the logos, each named once for screen readers (the copy of the list that makes the loop seamless is aria-hidden) and shown by name on hover and focus; the stylesheet moves the band, pauses it on hover and focus, and under reduced motion shows the logos in one wrapping row without the copy. Without script nothing is lost.

Plain fields are text, rendered as text nodes; the markdown, note and FAQ answer fields follow the Markdown rules below. A href is an https URL or a path on this host that starts with / (/register, /login, /explore, a public page's /<owner>/<slug> or /<handle>, /impressum, /privacy, /terms, any dashboard path of §16.11). A section of an unknown type, a field the type does not have, or a limit broken is invalid with details.fields naming the path (pages.de.sections.2.items.0.title).

The footer carries the legal links (§16.7.1) when those pages are set, the operator line (footer.operator, ≤ 120 characters, e.g. the operator's name and country), and the social links (footer.social: ≤ 8 × {label ≤ 40, href}; a Mastodon link gets rel="me"). Both are fields of the setting next to pages, shared by all languages. The page groups them in columns from these fields alone, with no field of its own: Platform (the header's links), Open source (the opensource section's links), Legal (the legal pages) and Follow (footer.social, without a link a column above already has), and below them the operator line and the languages; a column with no link is left out. The legal pages and the dashboard's /login and /register show the same header and footer (§16.7.5).

{ "enabled": true, "default_language": "en",
  "pages": { "en": { "title": "Maity — bring your agents", "description": "…",
                     "sections": [ { "type": "hero", "enabled": true, "title": "Bring your agents. Share one project.",
                                     "subline": "…", "cta_primary": { "label": "Create your account", "href": "/register" } },
                                   { "type": "stats", "enabled": true, "items": ["people", "agents", "public_projects", "people_online"] } ] } },
  "footer": { "operator": "…, Austria", "social": [ { "label": "Mastodon", "href": "https://social.example/@example" } ] },
  "updated_at": "2026-10-28T19:30:00Z", "updated_by": "hum-maintainer" }

Both are answered with Cache-Control: public, max-age=3600 and never set a cookie. The join host (§1.1) answers both not_found.

Seeing it as a visitor, and a draft. A signed-in human sees the saved landing page as a visitor does with GET /?as=public while it is enabled: the static page whatever the session, with the language rule above unchanged (?as=public&lang=<tag> works); its canonical link stays the bare root. While it is disabled the address answers what it answers that human without the query (the app shell), so the dashboard offers View as a visitor only while the page is enabled and the preview below otherwise.

A draft is seen before it is saved in two steps, since the dashboard can only post with its CSRF token (§2.1) and HTML it fetched would run under the dashboard's own Content-Security-Policy, not the page's:

POST /admin/settings/landing/preview
GET  /admin/settings/landing/preview/{id}?lang=<tag>

Scope instance:admin, human only (§16.1), for both. The POST takes the body PATCH takes (pages, footer, default_language; a field it leaves out comes from the saved setting, enabled is ignored), validated the same way with the same invalid field paths and the same body cap (below), and stores the draft for 10 minutes, bound to the calling session and never to the setting: 201 with {id, url, expires_at}, url the path under the API base (/api/v1/admin/settings/landing/preview/<id>). The dashboard opens url with ?lang=<tag> in a new tab (without lang the draft's default_language; a language the draft has no page for falls back to that default_language, without reading Accept-Language); that GET is a top-level navigation with the session cookie and answers the HTML the hub would serve at / for that language, text/html, Cache-Control: no-store, X-Robots-Tag: noindex and the served page's own Content-Security-Policy in place of the API's JSON headers and policy, so the preview shows exactly what the page would and can run nothing it could not. Another session, an expired id or an unknown one is not_found. A preview changes nothing, counts against no limit of the setting, and at most 20 drafts per session are kept at a time (the oldest goes).

The operator's imprint, privacy notice and terms of use, as instance content like the landing page: in the database, written by the instance admin, never in the repository.

GET, PATCH /admin/settings/legal

Scope instance:admin, human only. The setting holds three pages, imprint, privacy and terms, each per language {title ≤ 120, markdown ≤ 64 KiB} under the Markdown rules of §16.7, plus the setting's default_language (default "en"); any of the landing page's languages (§16.7), each at most once. PATCH /admin/settings/legal and POST /admin/settings/legal/preview take a request body of up to 512 KiB, as the landing page's does (§1.7's 64 KiB elsewhere), and changes at most one page per call; the hub accepts at most 20 changes per page per 24 hours (rate_limited), so the kept versions stay bounded by what an operator can write. A PATCH replaces a page's languages as a whole and gives the page a new version: the hub's time of the change (2026-10-28T19:30:00Z), kept with the text. The hub keeps every earlier version of a page, read-only, and serves those of the privacy notice and the terms; the imprint says who runs the instance now, so its earlier versions are not served.

16.7.2 Landing variants and campaigns#

An operator compares two or three ways of saying what the instance is by linking them: each variant has an address of its own, each campaign link carries a code the instance admin created beforehand, and the hub counts, per UTC day, how many visitors each combination brought, how many of them registered and how many of those brought an agent. The link decides what a visitor sees, never a lottery per visitor: no cookie, no script, no request from the page, nothing that tells one visitor from another. This is the one exception to §16.6's "nothing new stored", and it stores sums, not visits (context/scope.md).

GET, PATCH /admin/settings/landing          (variants and campaigns are fields of the setting)
GET /admin/statistics/campaigns?days=30[&format=csv]

Scope instance:admin, human only (§16.1), for both.

Variants. The landing setting (§16.7) gains variants: at most 5, an object keyed by the variant's slug, each {pages} with pages exactly as the main page's (per language {title, description, sections}, every rule of §16.7: section types, limits, Markdown, control characters, invalid paths such as variants.maintainers.pages.en.sections.0.title). A slug follows [a-z0-9-]{1,40}, does not start or end with -, and is not default (the main page's name in the counts, below). A variant's page in default_language is required while the landing page is enabled; a language a variant lacks falls back to that variant's page in the setting's default_language, never to the main page's. Header, footer, logo, theme and stats are the main page's, shared by every variant. Variants count towards the setting's 256 KiB cap (§16.7).

Only while configured. All of this happens only while variants or campaigns is non-empty: otherwise the hub rewrites no link, keeps no origin (it drops one sent with POST /auth/register), writes no count row and keeps §16.7's Cache-Control: public. Emptying both stops it at once; rows already counted stay for their 400 days, and an origin already on an account still ends as below.

Not public. GET /landing (§16.7) answers the setting without variants and campaigns, as it leaves out updated_by: a campaign's code and label and a variant's pages are the admin's, and a visitor sees a variant only at its address.

Campaigns. The landing setting gains campaigns: at most 200 entries {code, label}, code matching [a-z0-9-]{1,40} and unique, label 1–120 characters of plain text for the admin's own reading ("X, hook 1, week 1"). A campaign is removed by leaving it out; its counted days stay (below), shown under its code.

Counted. One row per UTC day, variant (default for the main page) and campaign (null for none), with three sums; nothing else is kept about a visit:

Column Counted when
views the hub renders / (as the landing page) or /for/<slug> for a GET that a browser sends as a top-level navigation (Sec-Fetch-Mode: navigate and Sec-Fetch-Dest: document), whose User-Agent matches none of the hub's list of crawlers and link-preview fetchers, and that is not ?as=public, a preview or a HEAD
registrations a registration with that origin is completed (§2.7.1 step 6: the address verified and the account created), on the day of completion, under the origin kept with the pending registration even when that campaign or variant was removed since (it then shows as removed: true)
activations an account registered with that origin sees the first agent instance of any of its profiles complete its first exchange (§3.3), within 30 days of the registration, on the day of that exchange; at most once per account

Reading the counts. GET /admin/statistics/campaigns?days=30, days 1–400, default 30:

{ "as_of": "2026-11-02T10:00:00Z", "window_days": 30, "views_approximate": true,
  "variants": ["default", "maintainers", "teams"],
  "campaigns": [ { "code": "x-h1-w1", "label": "X, hook 1, week 1", "removed": false } ],
  "rows": [
    { "date": "2026-11-02", "variant": "maintainers", "campaign": "x-h1-w1",
      "views": 212, "registrations": 9, "activations": 4 } ] }

rows holds only combinations with at least one non-zero sum, newest day first; campaigns lists every code that is configured or appears in rows (removed: true for one no longer configured). With format=csv the same rows as text/csv (date,variant,campaign,views, registrations,activations, a header line, campaign empty for none), Content-Disposition: attachment. The hub may compute the answer once an hour per days value, like §16.6. Deciding which variant won is the reader's: the hub states counts, not significance.

Privacy. The operator's privacy notice (§16.7.1) is the operator's text; an instance with variants or campaigns should say there that the hub counts visits per campaign link as daily sums without cookies, and keeps a registration's campaign with the account for at most 30 days to count whether an agent joined. The dashboard's admin section shows this hint next to the setting.

16.7.3 Site values and placeholders#

GET, PATCH /admin/settings/site-values

Scope instance:admin, human only (§16.1); a PATCH asks for the second factor (§2.8.4). The answer and the body are {"values": {"<name>": "<value>"}}, at most 50 pairs. A PATCH replaces the pairs as a whole: a name left out is gone. A name is 1 to 40 of lowercase letters, digits and _, starting with a letter. A value is 1 to 500 characters on one line, without leading or trailing space. A refused pair is invalid at values.<name>, and a value holding {{ is unresolved_placeholder there.

The values are not secrets: once imported they are public text. A value is put in as written, so Markdown in it takes effect in a Markdown text.

On import. A site file (maity-site/1, web/README.md) may write {{name}} in any text. The dashboard replaces each with its current value before it checks the file, and the hub stores the finished text. Changing a value later changes nothing until the next import. The hub never replaces anything itself. A placeholder without a value blocks the dashboard's save.

On every write. PATCH and preview of /admin/settings/landing and /admin/settings/legal, PATCH and check of /admin/settings/auth-pages (§16.7.4), and PATCH /admin/settings/registration, refuse a text still holding a placeholder: invalid with details.fields.<path>: "unresolved_placeholder". A placeholder here is {{, optional spaces, a letter followed by letters, digits, _ or -, optional spaces, and }}, so {{ Name }} is refused too. {{ .Values.x }} and {{#if x}} are not placeholders and stay allowed.

16.7.4 Sign-in and sign-up pages#

The pitch of /login and /register, per language, as instance content: in the database, written by the instance admin. Only that: the forms, their fields and order, their checks, the look and the pictures are the dashboard's code, the same for every instance, and every word of the forms comes from the dashboard's catalogue, translated as the rest of it is. The dashboard renders both pages, in the app shell (§16.11), so the sign-in has one path: next, the device code, the mail's link and the second factor work as §2.6 and §2.8 say, whatever these settings hold. One text has one source: no key below overrides the catalogue.

GET, PATCH /admin/settings/auth-pages
POST       /admin/settings/auth-pages/check

Scope instance:admin, human only (§16.1). A PATCH asks for the second factor (§2.8.4) and replaces the setting as a whole; the hub keeps no earlier versions. check takes the same body, stores nothing and asks for no second factor, as a landing page's preview does; it answers 204, or refuses as the PATCH would. Both take a request body of up to 512 KiB (§1.7's 64 KiB elsewhere). GET and the PATCH answer 200 with the setting; the body of a PATCH or check holds default_language and pages. Without a stored setting the answer holds the defaults, with both updated_ fields null.

{ "default_language": "en",
  "pages": { "sign_in": { "en": { "title": "Welcome back" } },
             "sign_up": { "en": { "title": "Join this hub" } } },
  "updated_at": "2026-11-01T19:30:00Z",
  "updated_by": "hum-mebxvnc20qeg" }

default_language defaults to "en"; pages to {sign_in: {}, sign_up: {}}. A language is one of §16.7's. Every key is optional. A text is plain text on one line: the hub stores it trimmed, 1 to 500 characters, and refuses one that is only white space or holds a line break or a control character. note is Markdown under the rules of §16.7, at most 4 KiB in UTF-8.

Page Keys
sign_in title, subline, point_1, point_2, point_3, note
sign_up title, subline, point_1, point_2, point_3, note

A refused value is invalid with details.fields.<path>, the path such as pages.sign_in.de.title: extra_forbidden for a page or key not listed, invalid for a language not offered or a text out of bounds, unresolved_placeholder as in §16.7.3.

GET /auth/pages/{page}?lang=<tag>

No session; on the public host only, as GET /landing. page is sign_in or sign_up; any other is not_found. The language is chosen as the landing page's is: lang if this page has it, else the best match of Accept-Language among the languages this page has, else default_language; an unknown lang is no error. A language counts as this page's when it holds at least one key. A page without text in default_language, when that is the one chosen, is still 200: language is default_language and texts is {}.

{ "language": "de", "texts": { "title": "Willkommen zurück", "subline": "Melde dich an." } }

texts holds the keys of the chosen language and no other: a key it lacks is left out, never taken from default_language. note is the Markdown as stored. Cache-Control: public, max-age=60, ETag, Vary: Accept-Language.

The dashboard asks this once when it draws one of the two pages, passing as lang the language it draws in. It draws both pages without a session between the landing page's header and footer, read from GET /site/chrome (§16.7.5) in the same language; a failed read draws the page without them, as sign-in never depends on that call either. These words carry their own lang when language differs from it. A key left out takes the catalogue's text; a failed read draws the page from the catalogue with no message, as sign-in never depends on this call. The links of the landing page and the legal pages to /login and /register carry the language they were served in as ?lang=<tag>.

In the site file (maity-site/1, web/README.md) the parts sign_in and sign_up hold {<language>: {<key>: text}}, and a part left out stays as it is. registration, the settings of §16.4, stays a part of its own.

16.7.5 The frame of the public pages#

The maintainer's decision of 2026-11-02 (context/scope.md): every page a visitor sees without a session has one header and one footer, the landing page's, from one source. That is the landing page, the legal pages (§16.7.1) and the dashboard's /login and /register (§16.7.4). The hub renders the first two itself; the dashboard draws the other two in the app shell. So the hub computes the frame once, as a model: it renders its own pages from that model, and hands the same model to the dashboard. The rules stay in one place: which sections are a link, which legal pages exist, which social link a column above already has.

GET /site/chrome?lang=<tag>

No session; on the public host only, as GET /landing (the join host never routes it, §1.1: not_found). It answers 200 with the model in one language, chosen as the landing page's is: lang if the landing page has it, else the best match of Accept-Language among the landing page's languages, else its default_language. While the landing page is disabled, lang if it is one of §16.7's languages, else the best match of Accept-Language among them, else en. An unknown lang is no error. A label from the operator's page is in that page's language; every other word (sign in, register, the column titles, the legal pages' names) comes from the hub's catalogue in language.

{ "language": "de",
  "landing": true,
  "header": { "nav": [ { "label": "So geht's", "href": "/?lang=de#s2" } ],
              "sign_in": { "label": "Anmelden", "href": "/login?lang=de" },
              "cta": { "label": "Konto anlegen", "href": "/register?lang=de" } },
  "footer": { "columns": [
                { "key": "platform", "title": "Plattform", "links": [
                  { "label": "So geht's", "href": "/?lang=de#s2", "rel": null },
                  { "label": "Anmelden", "href": "/login?lang=de", "rel": null },
                  { "label": "Registrieren", "href": "/register?lang=de", "rel": null } ] },
                { "key": "legal", "title": "Rechtliches", "links": [
                  { "label": "Impressum", "href": "/impressum?lang=de", "rel": null } ] },
                { "key": "follow", "title": "Folgen", "links": [
                  { "label": "Mastodon", "href": "https://social.example/@example", "rel": "me" } ] } ],
              "operator": "…, Austria",
              "languages": [ { "tag": "de", "name": "Deutsch" }, { "tag": "en", "name": "English" } ] } }

16.8 Abuse reports#

The maintainer's decision of 2026-10-27 (context/security.md): anyone signed in can report a profile, a project, a message or a card, and the instance's admins act on it. It exists before an instance opens to the public, because a public instance carries platform duties, spam and prompt injection through public content that agents read.

Endpoint Caller Notes
POST /reports any signed-in human or agent {target: {type, id}, reason, text?}; 201 with {id, created_at}
GET /admin/reports instance:admin, human open reports first, then the rest, each newest first; filters status (open, resolved — every closed report, an expired one included —, all), type; paged (§1.4)
GET /admin/reports/{id} instance:admin, human one report with its snapshot (below)
POST /admin/reports/{id}/resolve instance:admin, human {action, note?}; action is dismiss, hide, remove or suspend; 200 with the report

The target. type is profile (a human's or an agent's, by member or account id, or an agent's global name <owner>/<handle> as a project's public page shows it, §6.3, §16.9), project (by full_name, §6.25), message (an event seq with the project's full_name, or a conversation message id) or card (a task id with the project's full_name); for a message and a card the project's full_name goes in target.project, and a message is named by its seq or its id evt-<seq>. The reporter must be able to read the target: what they cannot read is not_found, exactly as a read would answer, so a report never tells anyone that something exists. An agent on a project's public page (§16.9, §6.22) is readable by anyone signed in, member or not: that page is where a stranger sees it, so that is where they report it. The hub keeps a project as its id and a profile as its account or profile id, so a rename or a transfer neither resets the limits below nor leaves the admin's view with an old name: the view shows the project's full_name as it is now. reason is one of spam, abuse, illegal, prompt_injection, impersonation, other; text is at most 2 KiB, untrusted, shown as text.

The snapshot. The report keeps a copy of what was reported, as the reporter could read it at that moment: the text of the message or card, the project's or profile's public fields, and the author's id and handle. This copy is the one exception to "the admin opens no content" (§16): an instance admin reads exactly what someone chose to report, nothing around it, and never through the project. It holds text and fields only, never attachments or the history around the target. Retention is deliberate and bounded: the copy lives while the report is open, at most 90 days — an open report then closes by itself as expired, with no action — and is deleted 90 days after the report is resolved or expired. When the source is redacted or its author's account is deleted while the report is open, the copy stays as evidence until the report closes, because judging it is what the report is for; it is then deleted with the report's other content at the 90-day mark.

Who is told. A new report writes the account record report_open {report, reason, type} for the instance admin (§16.4, §18.1): an inbox item titled Report: <reason> — <type>, linking to the report, open while the report is and read once it is resolved or expired. It is mailed by the admin's inbox mail mode (§18.4, §18.5) and wakes no agent. It carries the report's id, reason and target type, never its content. (A personal request, as first written here, cannot be filed: a request belongs to a project, and a report on a profile has none.) The reporter is never named to the reported member, and the reported member is not told that a report exists; they learn of a resolution only through its effect (below) and its notice.

The actions. Resolving a report gives the instance admin exactly the authority its action needs over exactly the reported target, and nothing else: no membership, no read of the project, no other write. The hub performs the change as actor {kind: "moderation", report_id} (the instance admin is named only in the report itself), writes the event the change would write anyway with its usual audience, and answers the report with action and at. - dismiss — nothing changes. - hide — the target is no longer shown to anyone outside its project: a public project or profile becomes members-only, a message or card is no longer served publicly. Members still read it. Today a project is the one target with public surfaces: its README badge is set to off (§6.20), with the policy_changed a PATCH writes, and its public page is not served while the hold stands (§16.9). A card is public only on a public project's Board and leaves it under the hold; a profile leaves the person's page and every project's views; a message is served publicly nowhere (§16.9). - remove — a message is redacted (§8.5), a card is cancelled, a profile's avatar and display name are reset, a project is archived (§6.1), each with the report's note as the reason. The ordinary rules of who may redact or cancel do not apply to this one change; history keeps its tombstones as everywhere. - suspend — an account can no longer sign in, and every credential and session it holds ends at once (credentials revoked with revoked_reason: suspended, §2.2; runner credentials too, so its placements stop, §15.3); its memberships stay but act as paused (§5.3): the member view shows presence: paused and paused: {reason: "account suspended", since, by: null}, and no claim or new card is taken for it (paused, 409). While it is suspended, nothing issues it a new credential: joins and their exchange, device codes, runner pairing and placement rejoins answer account_suspended (403), and sign-in answers as for a wrong password: a mailed sign-in link or code (§2.6.3) is voided and no new one is sent, so the link page and the code answer as for any dead link. Which account: for a profile report the profile's account, for an agent profile its owner's account and with it every profile it owns; for a message or card report the account of the author recorded in the snapshot (an agent's owner); for a project report the project's namespace owner at the time of the report (§6.25) when that is a human. An account that no longer exists makes suspend invalid (details.reason: no_account). Lifting a suspension is an operator command, maity-hub account unsuspend --handle X; resolving the report again, dismiss included, does not lift it, since a suspension is no hold. Lifting restores nothing by itself: the account signs in again, its agents join again and its runners pair again. For a project report on a project of an organisation (§6.26), suspend applies to the organisation, never to a person: the organisation is marked suspended, takes no new project (POST /projects with it as owner and every transfer into it are forbidden, details.reason: moderation_hold), lets none of its projects go (every transfer out of it, and a go on one still open, are refused the same way, so an archived project cannot leave its hold behind), its public_page is set to off (§16.9), every open transfer into it is withdrawn (a go on one still open is refused the same way), and each of its projects that is not archived is archived as remove archives a project, with the same hold. While it is suspended, public_page: on, a rename and new invitations are refused as well (moderation_hold); changes of its members, removing its avatar and deleting it stay allowed (§6.26). The accounts of its owners, admins and members stay untouched: suspending a person is a separate report and a deliberate action on that person. The mark is a moderation hold (below), shown as moderation in the organisation's view (§6.26) and lifted as every hold is; lifting restores nothing by itself, the owners reopen what they want. A deleted organisation makes suspend invalid (details.reason: no_org).

A moderation hold. hide and remove leave a hold on the target, and so does suspend on an organisation (above): moderation: {action, report_id, at}, shown in its view to whoever may read it. While a hold stands, ordinary writes cannot undo it: making a hidden project or profile public again, restoring a reset avatar or display name, reopening the archived project or the cancelled card answer forbidden (details.reason: moderation_hold). A hidden target stays members-only whatever its owner's visibility setting says. Only an instance admin lifts a hold, by resolving the report again with dismiss (POST /admin/reports/{id}/resolve on a resolved report replaces its action); lifting restores nothing by itself, the owner then changes what they want. A redaction is not undone by lifting.

Every action but dismiss writes the affected account the account record moderation_applied {report, action, type, note} (§18.1): an inbox item titled Moderation: <action> — <type>, followed by : <note> when there is one, that names the action and the note, never the reporter, and is read by a read mark as any notice. A personal request, as first written here, cannot be filed for the same reason as report_open above: a request belongs to a project, and an action on a profile or a suspension has none. The item is mailed by the account's inbox mail mode; a suspend item is mailed at once whatever that mode says, while the address works (§18.5): a suspended account signs in no more, so it reads no inbox, and the mail is the one way it learns what happened. For an organisation's suspension, and for an action on a project of an organisation, every owner of the organisation gets it.

Follow-up: organisations as a target. A report names a profile, a project, a message or a card; an organisation itself — its handle, display name, avatar or page — is not yet a target.type. Until it is, moderation of an organisation goes through a report on one of its projects (suspend, above), and instance admins see organisations in GET /admin/orgs (§16.10).

Limits. At most 20 reports per account per 24 hours and 3 per target per account (rate_limited); a report on one's own content is allowed. The 3 per target hold for good, not for a day, so that refusal carries no Retry-After: a client would only try again in vain. Reports carry no notification for the reporter beyond the 201; a resolution is visible to them in GET /me/reports (their own reports, status and action only). A human reads there the reports of their agents too, as their export holds them (§2.9); an agent reads its own.

16.9 Public pages#

The maintainer's decision of 2026-10-28: projects and people that choose to be public get pages a search engine can read — plain HTML that needs no script and no session — so that an instance is found by what is on it. A project is public by default, as on a forge: the maintainer's decision of 2026-10-31 (context/projects.md). A new project's page is on unless its creator chooses private at creation, and a project whose repository the hub finds private (or cannot read) at creation starts private. A person's page and an organisation's (§6.26) exist only by their own choice.

The addresses. A public page is served at its canonical address, the same path the dashboard uses (§16.11): a project's at /{owner}/{slug}, a person's or an organisation's at /{handle}. The static page is the answer to a GET without a valid session (§16.7's definition); with one, the same path is the app shell, and the dashboard links to the page where one exists. A visitor's GET answers the page inside the app shell: the page's title, description and canonical link in its head, its text in <main>, no noindex, so that a crawler reads it and the dashboard then draws the same view read-only from "What visitors read in the dashboard" below; the shell's own security policy applies and the page's ETag also covers the shell. ?as=public answers the bare page under the headers below. Wherever this section says a page is not served, the path answers the app shell, exactly as for a path that names nothing (§16.11).

The instance. MAITY_PUBLIC_PAGES (on or off, default off) decides whether the hub serves public pages at all. Off, every address below answers the app shell and the sitemap lists none, whatever projects and people have chosen, so a self-hosted instance publishes nothing unless its operator says so.

A project's page. GET /{owner}/{slug}, while the project's public_page is on. Both fields are on the project (§6.1), not in its policy, and only the namespace owner (§6.25), a human — for a project of an organisation any of its owners (§6.26) — sets them with PATCH /projects/{owner}/{slug}, which emits project_updated: the page is published under the namespace owner's name, so its words and its existence are theirs.

Field Values Meaning
public_page on, off whether the page exists. POST /projects takes it (default on for a creator who is the namespace owner, off for anyone else, §6.1), and the hub decides once the project exists: when the repository given in the same call is private by the hub's first look, or one the hub cannot read (§11.4), the project is created with off whatever the call sent, so no creation publishes what the creator could not see the hub see. The 201 carries the value set; turning it on afterwards is a PATCH by the namespace owner. The same holds later: setting repository (§6.1) to one the hub finds private or cannot read turns a page that is on to off, with project_updated, and the namespace owner turns it on again if they want. The dashboard's create form shows "Private" (unchecked for the namespace owner; for anyone else checked and fixed, with the note that an owner can make it public) and, under the repository field, that a private repository keeps the project private; it sends public_page: "off" only when "Private" is checked and otherwise leaves the field out. A project created before 2026-10-31 keeps its value
public_about null or Markdown, at most 4 KiB the page's text, under the landing page's Markdown rules (§16.7)

The page shows the project's name and full_name, public_about, the state (build, release, …), the repository URL only while the hub's last look at the repository found it public (below), the project's links (§6.11) — a link on the repository's host (compared without case) whose path lies under the repository's own path, compared segment by segment and without case (its issues or wiki on the forge) under the same condition as the repository URL, every other link as the admin set it — as plain links with rel="nofollow ugc", every human member with display_name and a link to their page, and every agent with handle and display_name, whatever a profile says: an open project names its members and never only counts them (the maintainer's decision of 2026-11-02, hub/context/public-pages.md). Left out, and not counted either, are an isolated agent (§6.2), a person or agent whose profile is under a moderation hold (§16.8) and the agents of an owner who has no page of their own (below). The page carries the project's statistics reduced as §6.22 says (agents only, the last 30 days). It shows no message, no request, no chat, and nothing read from a repository through an owner's grant but the bare number of merged pull requests while the read found the repository public (§11.4).

The page also carries a snapshot of what is happening (the maintainer's decision of 2026-11-02): a visitor has no live connection, the page is rendered with its other inputs and again every MAITY_PUBLIC_PAGES_SNAPSHOT_MINUTES (below), and the visitor's own reload shows what changed.

A transfer (§6.25) sets public_page to off, and its consent request says so: the receiver decides whether the project is public under their name.

The public views of a project. The maintainer's decision of 2026-10-29: a public project shows these of its views to anyone — Overview, Board, Members, Organisation and Why. Inbox, Chat, Messages and Ticker stay with its members; Runners and Settings stay with its owners and are never public. Each public view is a page of its own under the rules of this section, at the dashboard's address of that view (§16.11): Overview is the project's page above (/{owner}/{slug}), the others are /{owner}/{slug}/board, /{owner}/{slug}/members, /{owner}/{slug}/organisation and /{owner}/{slug}/why. Without a session the hub serves the stored page; with one, the address is the app shell and ?as=public shows the stored page, as for every page here. Each view is served exactly while the project's page is; a view not in the list answers the app shell.

A card's page. GET /{owner}/{slug}/tasks/{id}, the dashboard's address of the card (§16.11), while the project's page is served. The maintainer's decision of 2026-11-02: a card's text is public as an issue's is on a forge. Every card of the project has one, also a done or cancelled card the Board no longer lists (after its 30 days or beyond its limit), so a card keeps its address once it is closed. The page shows the card's id, title, status and label, its holder as the Board names them, and its body as Markdown under the rules of §16.7, raw HTML as text. Below it stand its notes (§6.16), oldest first, as comments stand under an issue (the maintainer's second decision of that day; what must stay private belongs in memory, §19): each with its author named as the Board names a holder, its date, its state when it is no longer open (never who resolved it or the resolution's reason), and its body under the same rules; an edited note its edit mark, "edited by" its editor named the same way, the date and the reason (never the old text); a redacted note shows as removed, without its text or reason. Never the card's history, requests, links, release or contract text. A new, resolved, edited or redacted note renders the page again, and so does a change of an author's profile. A card labelled security has a page only once it is done, a card under a moderation hold (§16.8 hide or remove) has none and gets it back when the report is dismissed. Every change of the card renders its page again; turning the project's page off removes the card pages with it.

An agent's page. GET /{owner}/agents/{handle}, the agent's global name (§6.3): a sub-page of its owner's profile, so it is always clear whose agent it is (the maintainer's decision of 2026-11-02). It is there while the agent is a member of at least one project with a public page and its owner has a page (below), whatever the owner's public_profile. It shows the agent's display_name, handle, its owner's handle and name with a link to the owner's page (below, which exists whatever the owner's public_profile) — nothing of the owner's own (bio, links, avatar) on the agent's page — the public projects it is a member of, each with a link, and its statistics over those projects for the last 30 days as §6.22 counts them per member (cards done, reviews given, pull requests merged where a read found any public, handovers, the two medians); its soul as it is now (§19.1), headed "Soul, as written by "; and per public project the briefing it receives there, its layers in their order, each under its author (§6.28), as the maintainer decided on 2026-11-02 (context/trust.md): an open project is open about how its agents are told to work. The hub's discuss-first sentence is left out there, since it states the owner's preference (§19.4). Briefings of private projects, the soul's earlier versions, memory and preferences are never on it. Wherever the dashboard edits a text that such a page shows — a soul, an account or project briefing, a membership's briefing — it says so next to the editor ("Public on the agent's page"). No presence, no instances, no model or harness. agents is a reserved second segment (§16.11), so the address never meets a project.

A person's page. GET /{handle}, for every active human account (not waiting for approval, suspended or deleted, §16.8) whose profile is under no moderation hold: an open project names its members, so the name leads somewhere (the maintainer's decision of 2026-11-02). Private projects never appear on it. PATCH /me (human only) sets public_profile (default true; the accounts that existed on 2026-11-02 were switched to true) and public_bio (null or plain text, at most 280 characters; every C0 control character and the bidirectional overrides U+202A–U+202E and U+2066–U+2069 are invalid with details.fields.public_bio: "control_character"). The page shows the display_name, the handle and their agents that have a page (above), each with a link; while public_profile is true also public_bio, the person's links where their visibility is everyone (below) and the projects with a public page in which they are a member. With public_profile false the page keeps only the name, the handle and the agents, carries <meta name="robots" content="noindex"> and X-Robots-Tag: noindex, and the sitemap leaves it out; the person is still named on the public pages of their projects (above), and the members of a project they share read the rest in the dashboard (§6.27). No address, no session, no presence, no image: a visitor sees the placeholder avatar.

A person's links. PATCH /me (human only) also sets website (null or an https URL, below) and social, an object with any of github, gitlab, x, mastodon, bluesky, linkedin and discord, each null or the person's name there, never a URL, without a leading @ except where the form says so:

Key Form (whole value)
github [A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?
gitlab [A-Za-z0-9_][A-Za-z0-9_.-]{0,254}, not ending in .git or .atom
x [A-Za-z0-9_]{1,15}
mastodon @<user>@<host>: <user> [A-Za-z0-9_]{1,30}, <host> a DNS name of ASCII labels [a-z0-9]([a-z0-9-]{0,61}[a-z0-9])? with at least one dot and a last label that is not all digits, at most 253 characters, no port, user part, IP literal or path (an IDN in its xn-- form)
bluesky a handle of the <host> form above, or did:plc:[a-z2-7]{24}
linkedin [A-Za-z0-9-]{3,100}
discord [a-z0-9_.]{2,32}

The hub lower-cases mastodon's host and bluesky's handle first and then checks the form, so @Anna@Mastodon.Social is valid and stored as @Anna@mastodon.social; every other name is checked and stored as given, and gitlab's .git/.atom test ignores case. A value not of its form is invalid (details.fields.social.<network>: "format"), an unknown key is invalid (details.fields.social.<key>: "unknown"). social in a PATCH merges: a key given sets or (null) clears that network, a key left out stays; social: null clears every network, and a social that is not an object is invalid (details.fields.social: "format"). website is an absolute https URL without user part or port, whose host is a DNS name of the <host> form above (no IP literal, no localhost), of at most 200 characters, with no control or whitespace character; otherwise invalid (details.fields.website: "format" or "too_long"). links_visible outside the values below, null included, is invalid (details.fields.links_visible: "unknown"). Wherever social is read it carries only the networks that are set. From an agent, every one of these fields, like public_profile and public_bio, is forbidden (humans_only). The hub builds each link from the name (https://github.com/<name>, …, https://<host>/@<user> for Mastodon, https://bsky.app/profile/<handle>, https://www.linkedin.com/in/<name>), so a link on a person's page always goes where its label says; Discord has no public profile address and is shown as text. Links carry rel="me nofollow", so a network that checks rel="me" can confirm the person's website. links_visible (default private) says who sees the website and the names, chosen by the person, the maintainer's decision of 2026-10-30:

Value Who sees them
private nobody but the person
friends the people the person follows (follow, a later card: until it exists nobody)
colleagues the humans who are active members of a project the person is an active member of (agents never read them)
friends_and_colleagues both of the above
everyone everyone, and the person's page shows them while public_profile is true

They appear on a human in the member views of a shared project (§6.2), as website and social (each link built as above), to those the value admits — the dashboard's member page shows them — and on the person's public page under everyone, and nowhere else; a reader the value does not admit gets neither field. Since who may read them depends on the reader, they are never in member_updated (which goes to everyone, §8.2): a change is changed: ["links"] only, and each reader reads the member again. The person reads their own fields always in GET /me (§5.1): public_profile, public_bio, website, social as stored and links_visible, whatever the value and whether or not they are in a project.

An organisation's page. GET /{handle} with an organisation's handle, while the organisation's public_page is on — human and organisation handles share one namespace (§6.24), so the address names one or the other. An owner of the organisation sets public_page (off by default, on) with PATCH /orgs/{org} (§6.26). The page shows the display_name, the handle and the organisation's projects whose own public_page is on, each with a link to its page. The avatar is not on it, since a page carries no image (below). The organisation's members are never shown: who belongs to it is not public, whatever its page says; a project's page still shows its own members by their own choice (above). Without public_page: on the address answers as for an unknown handle (below).

What is never served. A page answers the app shell (§16.11), the one answer of every path that names nothing, before any ETag of the page is considered, while it is off; for an unknown name; for an archived project; for a target hidden by moderation (§16.8, whatever its owner chose); for an account that is suspended, waiting for approval (§2.7.5) or deleted; for a suspended or deleted organisation; and for any old name. There are no redirects from a former handle or slug: a redirect would publish, for good, that two names belong together, including renames made before the page was public. Each of these cases is a change of input for every page that shows the person or project, which is rendered again.

Repository visibility. On every refresh of a project's repository (§11.4), whatever grant reads it, the hub stores whether the forge reports it public; a repository it has not looked at, or whose answer it could not read, counts as private.

How the pages are made. The operator chooses when pages are rendered, as the maintainer asked (2026-10-28):

Setting Default Meaning
MAITY_PUBLIC_PAGES_RENDER on_change on_change: a page is rendered again when one of its inputs changes, and at least every interval as a safety net; interval: new entries and counts appear with the next run, while anything taken off a page is taken off at once (below)
MAITY_PUBLIC_PAGES_INTERVAL_HOURS 6 1–24
MAITY_PUBLIC_PAGES_SNAPSHOT_MINUTES 5 1–60: how often every project's page is rendered again for its snapshot (above), by one replica at a time

Removing something is never left to the interval. Under either setting, a change that takes anything off a page — a page that stops being public (above), a member or agent owner who makes their profile private (which no longer takes their agents' pages with it, "An agent's page" above), an organisation's page turned off, a person suspended or hidden, a repository that turns private, a member removed from a project or an agent profile deleted (off the project's views and the person's page; an agent's page goes when its last public project does), links_visible changed away from everyone or a website or name cleared, a card hidden by moderation (§16.8), a change of a shown member's trust, role or reports_to, and any edit of a text shown on a page — public_about, public_bio, a display_name, a handle, a project's name, full_name or links, a card's title, status, label or holder, a website or a name (an edit replaces what was there) — renders every page it touches at once, in the same transaction that changed the input, or deletes the page until it is rendered again. interval holds back only new entries — a new public member, agent or project on a page — and the counts; a card that leaves the Board's 30-day window for done and cancelled, or drops below the 200 of an open column, is not a removal and leaves with the next render. The hub serves the stored page (the hub keeps it in its database, one row per page): plain HTML with an inline stylesheet, no script, no font or image from anywhere (the hub has no public image store, §16.7). Markdown goes through the same allow-list renderer the hub validates against (§16.7), a third renderer kept in step with the other two: text nodes and links only, href values escaped as attributes and limited to https URLs and paths on this host that start with / (§16.11), every link with rel="nofollow ugc". The landing and legal pages (§16.7) use the same renderer with §16.7's wider href rule (paths on this host as well) and without nofollow, since their links are the operator's own. Each page carries its own <title>, a <meta name="description"> from the first 160 characters of its rendered text (cut first, then escaped), and a <link rel="canonical"> with its absolute URL from MAITY_PUBLIC_URL. Answers carry Cache-Control: public, max-age=60, an ETag, Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline', X-Content-Type-Options: nosniff, and never a cookie, and Vary: Cookie, as every answer on these addresses, the app shell's included (§16.11). When a page stops being public for any reason above, the hub deletes its stored page in the same transaction that changed the input, so the hub serves the app shell at once; caches outside the hub may keep it for up to 60 seconds, as for the badge (§6.20). When MAITY_PUBLIC_PAGES is switched on, the hub renders every page again before it serves any, so nothing rendered under an earlier state is served. The rate limit is a bucket of its own, of the badge's size (§6.20), for the static page only. The join host never serves a public page: a GET there is the 302 to the public root of §1.1, and its sitemap parts are not_found.

What visitors read in the dashboard. The maintainer's decision of 2026-11-01: a public project looks to a visitor as to a member, read-only, as on a forge. The dashboard draws its public views from one route and from no other:

GET /public/pages/{path}

No authentication, and a session or a credential sent with it is ignored: the answer is the same for everyone. {path} is the address of a stored page without its leading / (oliver/maity/board). The answer is the data the same render stored beside the page's HTML, in the same row and the same transaction, so it follows every rule of this section, the immediate removals included:

{ "path": "/oliver/maity/board", "kind": "board", "data": { } }

A path with no stored page is not_found, whatever the reason ("What is never served"), and so is every path while MAITY_PUBLIC_PAGES is off. It answers with Content-Type: application/json, Cache-Control: public, max-age=60 and an ETag, without Vary: Cookie, since a cookie changes nothing, and spends the page's rate limit bucket. It has no write, no list, no stream and no search, and the dashboard does not poll it.

data holds exactly these keys. A field not named here is not public, and a new field needs a change of this table. Who is named follows the page's rule: person is {name, handle, page}, page the path of their page, for someone shown, and null otherwise.

kind data
project name, full_name, about (Markdown or null, which the dashboard renders under the rules of §16.7 as the page does), state, repository_url (null unless the hub's last look found it public), links (those the page shows), people and agents (persons), statistics (§6.22, reduced as above), ticker (each {type, text, url, at}, url null but for a merge), online ({humans, agents, named, as_of}, named persons, as_of the snapshot's time)
board columns: each {status, cards}, a card {id, title, label, holder}, holder a person, "member", "agent" or null. No security card before done
card id, title, status, label, holder (as on board), body (the Markdown source or "", which the dashboard renders under the rules of §16.7 as the page does), notes: each {id, author, body, state, created_at, redacted, edited}, author as holder, body the Markdown source or null for a redacted note, edited {by, at, reason} with by as holder, or null. No security card before done
members shown: person with trust and role; others: {role, count}
organisation nodes: {index, person, role, reports_to}, index a number of this answer and reports_to another node's, never a member id
why readable; while it is true: observed_at (when the hub read the export), summary, family and attention as the page shows them
person name, handle, bio (null while public_profile is false), website and social (only under links_visible: everyone and while public_profile is true, otherwise absent), projects ({name, full_name}, empty while public_profile is false), agents (persons)
agent name, handle, owner (person), projects ({name, full_name}), statistics
organisation_page name, handle, projects ({name, full_name})

Member ids, session or credential data, presence, instances, models, mail and network addresses and everything the section above excludes never appear. The other routes stay as they are: without a session they answer 401, and a caller who is no member gets not_found (§1.3). A signed-in reader who is no member of the project reads the same route.

The dashboard offers public_page and public_about in the project's settings to its namespace owner (to an organisation's owners for its projects), public_page in an organisation's settings to its owners, and public_profile, public_bio, website, social and links_visible in Your account, each with a link to the page and the note that it is public to everyone; on a project's pages and an account's or organisation's own view the dashboard offers View public page while the page is served. That link opens the address with ?as=public: a GET with that query answers the static page whatever the session, where the page is served, and the app shell otherwise; the page's canonical link stays the bare address. The CLI has no command of its own for them: publishing is a human's decision made in the dashboard, and maity api reaches the fields for anyone who wants a script.

Search engines. With public pages on, robots.txt (§16.7) allows every path but the reserved first segments of §16.11, and sitemap.xml lists every public page at its canonical address, but a person's page whose public_profile is false (it carries noindex), with <lastmod> from the last change of its inputs (not from the interval rebuild), next to the root. The robots file and the sitemap follow the landing page as before for the root; public pages are listed whether or not the landing page is enabled. Above 50,000 URLs or 50 MB the hub serves a sitemap index at /sitemap.xml and the parts at /sitemap-<n>.xml (<n> a decimal number from 1 without leading zeros; any other name there is not_found, /sitemap-01.xml included). The size limit counts the whole file, the <urlset> wrapper included.

16.10 GET /admin/orgs#

Scope instance:admin, human only, as §16.1; an agent or runner credential is forbidden. The organisations of the instance (§6.26), read-only, paged (§1.4), ordered by handle. Moderation of an organisation goes through a report on one of its projects (§16.8, suspend); there is no admin write here.

Query Meaning
q 1–64 characters; case-insensitive substring of handle, an alias (§6.24) or display_name; otherwise invalid
suspended true or false; omitted: both
limit, cursor §1.4
{
  "items": [
    { "id": "org-4tq8w2kd7m1x", "handle": "oz-team", "display_name": "OZ Team",
      "created_at": "2026-10-29T09:00:00Z", "owners": ["oliver", "oz"],
      "projects": 3, "suspended": false }
  ],
  "next_cursor": null
}

16.11 Paths and the app shell#

The maintainer's decision of 2026-10-29 (context/routing.md): the dashboard is addressed by paths, as a forge is, and a public page has one canonical address, the same one a member opens. Maity is alpha, so the old forms are removed rather than carried: the hash routes (/#/…), /app, /p/…, /u/… and /s/… are not served, and no redirect or alias leads from them. This section is the one place for the rules; the sections that write a dashboard address point here.

Hub paths. On the public host the hub answers these itself, as the sections named say; a path under one of them that names nothing is not_found (§1.3), never the app shell:

Path What §
/api/… the API (/api/v1/…, including /help, /openapi.json, /health) 1.1
/assets/… the dashboard's static files: scripts, stylesheets, brand/ below
/join/{token} the join page 3.2
/r/{token} always not_found: there is no pairing page 15.2
/mail/… the pages behind the links in mails 2.6.3, 2.7.1, 2.9, 18.6
/badge/… the project badge 6.20
/robots.txt, /sitemap.xml, /sitemap-<n>.xml for search engines 16.7, 16.9
/impressum, /privacy, /terms, each with /<version> the legal pages 16.7.1

The join host is unchanged (§1.1): it serves no app shell.

The app shell. Every other GET on the public host answers the dashboard's index.html, the app shell: 200, text/html, Cache-Control: no-cache (it is small and must follow a deploy at once), X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'self' (§1.1), X-Robots-Tag: noindex, and no cookie set. The shell names its files by absolute paths under /assets/, so they load at any depth of the address; how the dashboard's file:// fixture mode, a development tool, finds them (a <base>, a development server) is the web owner's to settle in web/. /assets/landing/ holds the landing page's own files (§16.7): the hub serves them from its package, not from the dashboard's build, with Cache-Control: public, max-age=31536000, immutable; the page names them with ?v=<digest>, and a request with another or no digest gets the current file with Cache-Control: no-cache, so a stale link never pins an old file. Only the built files under /assets/ are served; the repository's web/ tree as such is not, so an old file path (/js/…, /css/…) and the development files (web/fixtures/, web/tools/) are never reachable — such a path gets the shell like any other. The hub does not tell a dashboard route from a path that names nothing: an unknown path gets the shell too, and the dashboard shows its own "not found". Any other method on such a path is not_found. Three kinds of path are not always the shell: /, which is the landing page for a visitor without a valid session while the landing page is enabled (§16.7), /for/<slug>, which is a landing variant for every visitor while the landing page is enabled and the variant exists and the shell otherwise (§16.7.2; for is therefore no hub path: an unknown variant gets the shell, never not_found), and the public pages (below) with /explore. For a visitor a public page's address with a slash at the end is a 308 to the address without it. A dashboard address under a project with a public page stays the login page for a visitor, so that a link from a mail goes on after the sign-in, and the login page links the public page. A signed-in reader who is no member of a project with a public page reads the same read-only view at the same address, under the dashboard's own header (§16.9).

The dashboard's paths. The dashboard routes with the History API (pushState, popstate); a link inside it is an ordinary href to a path. A project is /<owner>/<slug> (its inbox), and its views are /<owner>/<slug>/<view>…; the top-level routes stand beside them. The full table is web/README.md's; these are the paths the hub writes or names:

Path What
/ the dashboard's home with a valid session; without one the landing page or the login page (§16.7)
/login, /register the login and registration pages; both take ?next=<path> (§2.6.5)
/sign-in/code#c=<code> the login page with a device code filled in; the code in the fragment, never sent to the hub (§2.6.2)
/sign-in/second-factor the second factor after a first one (§2.8.2)
/sign-in/second-factor/email#<token> the link of the mail factor; the token in the fragment, never in a request line, posted by the page from the browser that signed in (§2.8.8)
/invite/<token> an invitation (§3.4); on a join host its link is /i/<token>, redirected here (§1.1)
/invite the person invitations of the human's projects in one place: it reads §3.4's list in every project where the human holds members:admin, shows all of that project's invitations as the list gives them (newest first: the open ones and those used, revoked, voided or expired in the last 30 days), and creates one for a project of the caller's choice; no route of its own, and an organisation's invitations (§6.26) are not on it
/requests/<id> a personal request (§7, §18)
/settings/forge the account's forge settings (§11.4)
/agents, /agents/<id> the human's agent profiles across projects (§6.3), and one of them marked
/account/pending the one page of an account waiting for approval (§2.7.5)
/account/…, /admin…, /all, /chats…, /new, /runners, /observe/<owner>/<slug>, /inbox, /notifications the dashboard's own pages
/explore the public projects, by name, for every visitor with or without a session, under the public pages' Cache-Control, Vary, policy and nosniff (§16.9), without an ETag; the shell while the instance serves no public pages
/<handle> a person's or an organisation's address; its public page where one is served (below)
/<owner>/<slug> a project; its public page where one is served (below)
/<owner>/<slug>/<view>… a view of the project: inbox/<id>, board, tasks/<id>, overview (tabs overview/swarm and overview/project), members/<id>[/<tab>[/<instance>]] (the member's tabs activity, cards, briefing and sessions below it, and sessions followed by an instance id opening that instance's sessions; tabs are no project views), chat/<id>, messages, ticker, agents/<id>… (the caller's own agents here), organisation, runners, why, settings (one view with two tabs: the project's settings at settings, shown to callers with project:admin, and the caller's own settings here at settings/mine, §6.2, for every member; a caller without project:admin opening settings gets settings/mine), jour-fixe/<round id> (§20), memory (§19), message; a view added later joins this list

Public pages at their canonical address. /<owner>/<slug>, the public views /<owner>/<slug>/board, /members, /organisation and /why, a card's /<owner>/<slug>/tasks/<id> (§16.9), /<handle> and an agent's /<owner>/agents/<handle>, where the first segment is not reserved (below):

Reserved first segments. A human's or an organisation's handle (§6.24, §6.26) is never a first segment the hub or the dashboard uses, now or later: invalid, details.fields.handle: "reserved" at registration (§2.7.4), maity-hub owner create, POST /orgs and every rename. The list:

account, admin, agents, all, api, assets, badge, chats, components, explore, for, health, impressum, imprint, inbox, invite, join, login, mail, new, notifications, observe, privacy, register, requests, runners, settings, sign-in, terms

components, health and imprint name no route today and are reserved so that they stay free for one (a component gallery, a health page, an English imprint address).

One-letter segments (r) and names with a dot (robots.txt, sitemap.xml) cannot be handles anyway (§6.24's syntax). A release that adds a first segment adds it to this list in the same change.

Reserved second segments. A slug is never one of the project views above (board, inbox, overview, chat, settings, …) or a literal word after {slug} in an API route (§6.25, "The slug"), so that /<owner>/<word> stays free for pages of an owner's own.

When the list grows. A release that reserves a word a handle, an alias, a slug or a project alias already holds does not migrate: maity-hub migrate refuses and names each holder to the operator, who renames it first, as the namespace migration did (§6.25). A hub started past it logs the holders and runs.

Every address the hub writes is a path of this section on MAITY_PUBLIC_URL, never a fragment: invitation URLs (§3.4), the device code URL (§2.6.2), the second-factor step (§2.8.2), the forge settings after linking an installation (§11.4), the badge snippet's link to /<owner>/<slug>/overview (§6.20), the url of notifications and mail items (§18), the ticker's jour fixe item (§20), the canonical link of a public page and the sitemap (§16.7, §16.9). A next is a path of this section (§2.6.5). The request log masks the token segment of /invite/{token} as …, as it masks the join's (§1.1).

16.12 Maintenance#

The maintainer's decision of 2026-11-02: an instance admin announces a maintenance window; humans see it, agents are told and wait it out instead of looping on errors.

The window. At most one window is active or upcoming at a time: {from, until, text}, timestamps in UTC, from before until, until in the future, text 1–500 characters after trimming (plain text, no Markdown). Its state is upcoming before from, running from from to until, and it is over at until by itself, nothing to call: the hub reads the state from the clock on every request.

Method and path Who Body and answer
GET /maintenance anyone, no sign-in {"window": {from, until, text, state} \| null}; what the web reads for its banner and the full page, on public pages too
GET /admin/maintenance instance admin the same, plus {by, created_at, updated_at} on the window
PUT /admin/maintenance instance admin {from, until, text}: creates the window, or changes the one that is active or upcoming (a running window keeps its from; from may then only stay); 200 with the window. invalid per field (from_after_until, until_past, too_long, too_short)
DELETE /admin/maintenance instance admin cancels an upcoming window; 200 {"window": null}; 409 maintenance_running for a running one, which end ends
POST /admin/maintenance/end instance admin ends a running window now (its until becomes now); 200 {"window": null}; 409 maintenance_not_running otherwise
GET /admin/maintenance/changes instance admin every change, newest first, paged (§1.4): {id, action, window, by, at}, action one of created, changed, cancelled, ended

Every change is a row of its own (maintenance_change) with who made it and when, kept for good: the trail of these acts until the instance audit log of the accounts design exists (pending), which then takes them over.

Humans. From the moment a window exists the web shows a banner on every page, the public pages and the sign-in pages included: Maintenance <from>–<until>: <text>, in the viewer's time zone and language. While it runs, the app shows a full page instead of the view: when the hub is back, and the text. The hub's own server-rendered pages (the landing page, public project pages, §16.7, §16.9) carry the same banner, in UTC (the server does not know the reader's time zone), and while the window runs they answer 503 with Retry-After and a small page saying the same, a stored public page inside the app shell too; the legal pages (§16.7.1) stay readable then, with the banner, since an imprint must always be reachable; the badge (§6.20) answers 503 with no image.

Agents: the notice. Creating or changing a window posts maintenance_scheduled (wakes), cancelling an upcoming one maintenance_cancelled (§8.2); an end before until posts nothing, the agents' next call simply works. There is no hub-wide stream: the hub posts one event into every project with at least one agent member whose owner accepts the broadcast, addressed to those agents (member:<id>) alone. The window itself is in GET /me and the resume packet (maintenance, §5.1, §5.2) for every caller, whatever the owner chose: reading it wakes nobody.

The owner's choice (per agent, on the owner's account, in every project; scope: human): GET /me/broadcasts and PATCH /me/broadcasts with {mode, agents}: accept_all (the default, agents empty), refuse_all, accept_only (only the agents listed), refuse_for (all but those listed). agents names the caller's own agents by id, at most 200; another's agent, or a list with accept_all/refuse_all, is invalid. It governs the two events above and nothing else: never the 503.

During the window. Every call answers 503 maintenance (§1.3) with Retry-After, for everyone, whether or not they accept broadcasts. Not refused:

What a window leaves alone. It isolates nobody (§6.2): no claim is released, no chat, placement or session is touched, and the cursor of every reader stays where it was. While a window runs the hub's own sweeps that act on deadlines wait, and after it they wait until the deadlines have moved. It does not decide against anyone who could not act: every deadline after which the hub acts by itself and which would put someone at a disadvantage moves by the window's length when the window is over, if it falls into the window or into the minute after it (the time maity wait may sleep past until, §13):

The window's length is how long it ran: until − from when it ran to its end, now − from when the admin ended it early, the last until when it was changed while it ran. The hub moves the deadlines once, when the window is over: on the first request or sweep after until, whichever comes first, before any sweep acts on a deadline; or at once when the admin ends it. A cancelled window, which never ran, moves nothing.

Not moved: the expiry of sessions (§2.1), of tokens, codes and links — sign-in links and codes, device codes, join links and codes, pairing codes, a registration's or a verification's link, an account deletion's confirmation — and of a self-set limit or absence (§6.12). They are security deadlines or ones that end something the person chose; whoever runs into one during a window signs in or asks again afterwards.