maubot-plugins/docs/community-organizer-spec.md
Padreug d089a4b021 docs(spec): reframe identity model around operator-IdP + sidecar bunker
Updates §7.2, §7.3, §12 to reflect the actual architecture from
aiolabs/lnbits#9 (reframed since the earlier commit) and #18 (the
concrete phase 2 bunker integration using nsecbunkerd).

Three shifts:

- LocalSigner demoted to transitional/migration helper. RemoteBunker
  Signer is the steady state for every bound user. New accounts MUST
  NOT default to LocalSigner. Earlier framing treated them as
  equivalent choices — they're not.

- Binding artifact is a per-device NIP-46 connection token with
  scoped permissions, not just a (mxid → user_id) mapping row. Calls
  out the security property: compromise of one client device
  (tracker, ATM, webapp) leaks only that token's scope, not the
  user's full identity. Revocation is one RPC at the bunker.

- §12 redrawn around the operator-IdP-with-sidecar-bunker pattern.
  Names nsecbunkerd as the canonical bunker for the aiolabs ref
  impl, points at #9 + #18 for the LNbits side. Pattern is reusable
  beyond LNbits — any operator providing identity-as-a-service can
  run this shape.

NIP-26 explicitly out (Nostr ecosystem has deprecated; NIP-46 covers
the use case). §11 open questions trimmed accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:19:56 +02:00

35 KiB
Raw Permalink Blame History

Community Organizer Spec

Status: Draft 0.1 Authors: aiolabs / Château du Faune (Ariège, France) License: CC0 — adopt, fork, remix freely

A protocol for coordinating the day-to-day operations of a physical community — tasks, reminders, shopping lists, journals, infrastructure backlog — over chat (capture) and an open event store (canonical record), so the same data can drive multiple display surfaces (eink panels in shared spaces, web dashboards, mobile, etc.).

This document is the contract. Implementations are interchangeable: a Matrix maubot plugin, a Discord bot, a CLI, a web form, an SMS gateway — all can produce the same events and consume from the same store.


0. Why a spec, not just an app

Every community organizing itself eventually invents some flavor of "the list of stuff we need to do, the log of what we did, the things we need to buy". Tools rot, maintainers move on, the data gets stuck in proprietary stores. Communities re-implement the same wheel.

This spec puts the data on open protocols (Nostr, NIP-52, NIP-72) so that:

  • Multiple input surfaces can write to the same record
  • Multiple output surfaces (displays, dashboards, mobile) can consume without coordination
  • A community's organizational memory survives any single tool
  • Other communities can adopt the same shape and benefit from interoperable implementations
  • Self-hosting is achievable end-to-end — no SaaS lock-in

The spec deliberately reuses existing standards (RFC 5545 VTODO, NIP-52, NIP-72, ActivityStreams vocabulary) rather than inventing new ones. If we need something a NIP doesn't cover, we lean on tag conventions before proposing a new kind.


1. Prior art

A community-organizer-via-chat-bot isn't new territory — there's a lot to pull from rather than reinvent. Each item below names what we're borrowing and why it maps to our context.

  1. GTD (Getting Things Done, Allen, 2001) — the capture/clarify/organize/reflect/engage workflow. The observation that "clean the wound on the dog should be a task, not a journal entry" is literally the GTD "clarify" step. Capture is frictionless and unbucketed; clarification happens on a cadence. This is the conceptual backbone for an !add-style capture surface that later sorts into typed buckets (rules → LLM → human override).

  2. Bullet Journal (Carroll) — daily log + signifiers (•, ○, ☐) + "migration" (moving incomplete items to the next page). Maps cleanly to a !journal daily-log + a weekly review that migrates open items into tasks. The persistence-across-time framing ("new person arriving can reference") is exactly Bullet Journal's migration archive.

  3. iCalendar VTODO (RFC 5545) — battle-tested TODO data model: STATUS (NEEDS-ACTION / IN-PROCESS / COMPLETED / CANCELLED), PRIORITY, DUE, COMPLETED, CATEGORIES. NIP-52 is loosely modeled on iCalendar; aligning our task shape to VTODO gives us interop with the entire CalDAV ecosystem essentially for free.

  4. ActivityStreams 2.0 / ActivityPub — Actor-Verb-Object-Context vocabulary. The semantic primitive is "actor did verb on object in context", which is what every !command is. Established vocab.

  5. MagicMirror² + Home Assistant Lovelace — proven modular display pattern: data sources feed a render graph, plugins/cards subscribe. The lesson for output surfaces: don't bake a layout, expose a scene/card plugin API.

  6. CalDAV/CardDAV — the architectural lesson: standardized data on a shared store enables a plurality of input/output clients. The store and schema are the contract; clients are interchangeable.

  7. Linear's Project / Cycle / Issue triad + Loomio's proposal/decision flow — modern open-source patterns for triage and decision-making at the community level. Linear's status lifecycle (Triage → Backlog → Todo → In Progress → Done → Canceled) is borrowable.

  8. Logseq / Obsidian daily-notes — capture appended to a daily log, structured later via block refs/tags. Validates the "log first, structure second" posture and shows how tags become the organizational graph.

  9. maubot/reminder, maubot/rss — Matrix-side prior art for command shape, recurring schedules, room-scoped subscriptions. Worth reading before writing handlers.

  10. NIP-52 + NIP-72 (Nostr) — Nostr-native primitives for calendar events and community scoping. Reusing these instead of inventing new kinds is what makes the protocol portable to anyone with a Nostr relay.

The generalization insight: the bot is one reference implementation of a protocol-level contract. The contract (event shapes + community model + vocabulary) is what other groups adopt; their implementations can be different bots, different UIs, different displays.


2. Roles

Role Description
Member A human in the community. Identified by chat handle (e.g. MXID for Matrix). May or may not have their own Nostr identity.
Bot An automated process holding one Nostr keypair, capturing input from a chat surface and writing to the relay. The bot acts as a trusted publisher on behalf of members in a single trust boundary (one chat room = one community).
Community A trust boundary. Defined by a NIP-72 community definition event (kind 34550). Maps 1:1 to a chat room. Members of the room can record, edit, query within the community.
Relay A Nostr relay (or set of relays) holding the canonical event log. May be public, community-private (auth-gated), or both.
Renderer Any output surface that subscribes to the relay and displays events: eink panel, web dashboard, mobile push, voice assistant, etc.

3. Vocabulary

3.1 Universal verbs

Every conformant implementation MUST recognize these verbs. Verbs are the surface form — the underlying event is the same regardless of whether the user typed !task #buy milk or !buy milk (via a configured shortcut) or !add we need milk (LLM-classified into task #buy).

Verb Purpose Underlying kind
!add <text> Frictionless inbox capture; classifier sorts later 31922 with ["t", "unclassified"] initially; tag mutated on classification
!task <text> [#tag…] Record an actionable task 31922 (date-based) or 31923 (timed) with ["t", "task"] + user tags
!sidequest <text> [#tag…] Record an optional / passion-project item, distinct from the community's core tasks 31922 with ["t", "sidequest"] + user tags
!journal <text> Past-tense log entry; append-only; never "done" 31922 with ["t", "journal"]
!remind <when> <text> Time-bound prompt that fires a chat ping at due_at 31923 with ["t", "remind"] + start tag
!done <id-or-recent> Close a task or sidequest 31925 status event with accepted / equivalent
!list [type] [#tag…] Query recent items in current community read-only
!setup Per-community configuration (verb shortcuts, publish posture, etc.) writes to community config (see §6)

Note on !sidequest: Sidequests are intentionally separate from tasks — they're the passion-project / experimental / "if-you-have-time" items a community wants to track without cluttering the core task list. Same lifecycle as tasks (open → done) but renderers SHOULD surface them in a distinct view and exclude them from default !list task results. Lets communities keep day-to-day operations clean while still capturing the "wouldn't it be cool to…" backlog.

3.2 Lifecycle states

Aligned with RFC 5545 VTODO STATUS values:

State VTODO equivalent Notes
unclassified NEEDS-ACTION Inbox; awaiting classification
open NEEDS-ACTION Classified, not started
in_progress IN-PROCESS Someone's actively working it
done COMPLETED Closed via !done
canceled CANCELLED Closed without completion

Journal entries do NOT participate in this lifecycle — they're append-only. !done on a journal entry is undefined behavior; implementations SHOULD reject it.

Sidequests use the same lifecycle as tasks.

3.3 Tag conventions

NIP-52 events use ["t", "<tag>"] tags for free-form categorization. This spec uses:

  • A kind tag (exactly one): task, sidequest, journal, remind, unclassified — identifies the entry's role in the lifecycle.
  • Domain tags (zero or more): buy, steward, kitchen, animals, harvest, etc. — community-specific categories.
  • A priority tag (zero or one): see §3.3.1.
  • A classification-source tag (exactly one): src:explicit, src:shortcut, src:rules, src:llm, src:manual. Records how the entry was bucketed; useful for audit and for tuning the classifier.

3.3.1 Priority scale

Five colloquial levels for everyday capture, RFC 5545 VTODO PRIORITY compatible (VTODO is 1..9 with 1 highest):

Tag Label Meaning VTODO PRIORITY
priority:1 urgent Drop everything; time-critical 1
priority:2 crucial Critical importance; do this week 3
priority:3 important Matters; do when possible 5
priority:4 future Backlog; someday/maybe 7
priority:5 frequent/ongoing Recurring obligation that's never permanently "done" 9

Renderers MAY surface labels instead of numbers; the on-the-wire tag is priority:N for sort stability.

Note on priority:5 (frequent/ongoing): This level is a v1 shorthand for items that recur on a cadence (daily chicken feeding, weekly market run, monthly equipment check). A proper recurrence model (RFC 5545 RRULE equivalent) is in §11 open questions. Until then, !done on a priority:5 item is permitted but treated as "done for this cycle" — the user re-captures next time. Renderers SHOULD surface these in a distinct "recurring" view so they don't mask urgent work.

Example complete tag set for a !buy door handles typed via the buy shortcut configured for the room:

["t", "task"]
["t", "buy"]
["t", "src:shortcut"]

3.4 Per-community shortcuts

Communities declare their own surface verbs that expand to a universal verb + tags. Example (Ariège):

verbs:
  buy:     { kind: task, tags: [buy] }
  steward: { kind: task, tags: [steward, priority:4] }
  kitchen: { kind: task, tags: [kitchen] }
  urgent:  { kind: task, tags: [priority:1] }      # shortcut for a priority level
  chores:  { kind: task, tags: [priority:5] }      # recurring/ongoing items

Priority shortcuts (!urgent fix the leak) expand to !task #priority:1 fix the leak. Same per-community config mechanism — no special-casing of priority verbs.

A bakery co-op's config might declare harvest, market, brewery instead. The spec mandates the universal verbs; per-community shortcuts are configuration, not protocol. This means a renderer written for one community works for another without code changes — it just filters on different tags.


4. Event shapes

All events follow NIP-52 (calendar events) and are scoped to a community via NIP-72 a-tag.

4.1 Task / journal / unclassified entry (kind 31922)

Date-based event (no specific time). Used for items where the capture moment matters more than a scheduled time.

{
  "kind": 31922,
  "pubkey": "<bot-pubkey>",
  "created_at": 1716559200,
  "tags": [
    ["d", "<unique-identifier>"],
    ["title", "<one-line summary, truncated from body>"],
    ["t", "task"],
    ["t", "buy"],
    ["t", "src:shortcut"],
    ["a", "34550:<community-pubkey>:<community-d-tag>"],
    ["client", "<bot-name>", "<bot-version>"],
    ["author", "<originating chat handle, e.g. @alice:matrix.example>"]
  ],
  "content": "door handles from Laura",
  "id": "<computed>",
  "sig": "<computed>"
}

Required tags:

  • d — replaceable-event identifier; unique within (kind, pubkey). Recommend <community-d>:<random> or a ULID.
  • title — short summary for renderers; ≤80 chars.
  • exactly one kind tag from §3.3
  • a — NIP-72 community reference (see §5).
  • author — original chat handle for attribution. The bot signs as itself, but human accountability needs to be visible.

Optional tags:

  • Domain tags, priority tags, classification-source tag (§3.3)
  • client — bot identifier + version
  • e — references to related events (e.g. a journal entry that spawned a follow-up task)

4.2 Timed entry (kind 31923)

Used for !remind and any task with a specific time. Adds:

"tags": [
  ...
  ["start", "<unix-timestamp-seconds>"],
  ["end", "<unix-timestamp-seconds>"]      // optional
]

If only start is present, the entry is a point-in-time prompt.

4.3 Status / done (kind 31925)

NIP-52 RSVP event. Used by !done to close a task:

{
  "kind": 31925,
  "tags": [
    ["d", "<unique-identifier>"],
    ["a", "31922:<task-pubkey>:<task-d-tag>"],
    ["status", "accepted"],
    ["a", "34550:<community-pubkey>:<community-d-tag>"]
  ],
  "content": "<optional completion note>"
}

status values per NIP-52: accepted (we map this to done), tentative (we map to in_progress), declined (we map to canceled).

4.4 Deletions (kind 5)

Standard NIP-09 deletion event. Used to retract an entry recorded in error. Renderers MUST honor deletions.


5. Community model (NIP-72)

Every community is identified by a kind 34550 community definition event published by the community's founder (or the bot on their behalf at !setup time). The community is referenced by an a-tag:

a = "34550:<community-pubkey>:<community-d-tag>"

All entries scoped to a community MUST include this a-tag. Renderers filter on it to surface the right community's data.

5.1 Community definition event

{
  "kind": 34550,
  "pubkey": "<community-owner-pubkey>",
  "tags": [
    ["d", "<short-identifier, e.g. cdf-animals>"],
    ["name", "Château du Faune — Animals"],
    ["description", "Daily chores and infra for alpacas, hens, ducks, LGDs"],
    ["image", "<optional URL>"],
    ["moderator", "<pubkey>", "<relay-url>"],
    ["relay", "<wss://...>", "author"],
    ["relay", "<wss://...>", "requests"]
  ],
  "content": ""
}

5.2 Chat room ↔ community mapping

A conformant bot SHOULD maintain a 1:1 mapping from chat room → NIP-72 community. The room IS the trust boundary — anyone in the room can record/edit/close in the community.

Lazy creation is permitted: the first !setup (or first capture event) in a room MAY create the community definition.

5.3 Publish posture

Each community declares its publish posture (configurable via !setup):

Posture Behavior
matrix-only Bot records to local cache; does NOT publish to any relay. Use for sensitive rooms.
community-relay Bot publishes to the community's own relay (typically authenticated, member-only).
public-relays Bot publishes to all configured public relays. Use for outward-facing rooms (events, marketplace).

The posture is a hint to the publisher — the spec does not mandate relay authentication; that's the relay operator's choice.


6. Inbox and classification

The !add verb supports frictionless capture for low-literacy users who shouldn't have to remember verb taxonomy. Entries land immediately as kind: unclassified and progress through:

captured (unclassified) → rules classifier → [classified] OR [inbox]
                                                            ↓
                                          LLM classifier (optional) → [classified]
                                                            ↓
                                                manual review → [classified]

Classification is never a gatekeeper. The entry is always recorded immediately. Classification mutates tags asynchronously and is reversible by:

  • a chat reaction on the bot's ack message (UI-defined)
  • the !sort or !reclassify verb (implementation-defined)
  • direct edit by a moderator

The classification source MUST be recorded in the src:<rules|llm|manual|...> tag. This preserves audit trail and enables classifier tuning.

6.1 Conformance levels for classifiers

  • Level 0 — no classifier. Entries captured via !add stay unclassified forever until human-sorted. Acceptable for small communities.
  • Level 1 — rules-based. Keyword/regex matchers cover the obvious cases (e.g. "buy/purchase/order" → task #buy; past-tense verbs → journal). Transparent, deterministic, no external dependency.
  • Level 2 — LLM fallback. Anything the rules don't catch is classified by a small local model (or external API). Recommended: local-first to preserve the self-hostable property.

7. Permission model

7.1 v1 (trust-the-bot)

  • The bot owns one Nostr keypair.
  • All events for the community are signed by the bot.
  • Human attribution is carried in the author tag (originating chat handle).
  • Trust derives from chat-room membership: if you're in the room, you can record/edit/close in the community.
  • Relay operators MAY auth-gate (require NIP-42) to prevent community-scoped writes from non-members. Out of band coordination.

7.2 Per-user signing (v2; design-for-it now)

Once external-identity binding is in place (see §7.3), the bot SHOULD sign events as the originating user, not as itself. The chat-room → community mapping stays the same; only the signer changes.

Implementations MUST design their signing layer as an abstraction so per-user signing plugs in without refactor. Recommended interface (adopted from the LNbits reference implementation, see §12):

Signer Holds Where it signs Lifecycle role
BotSigner One server-side keypair shared across all events Server-side, in-process The v1 fallback and the unbound-user fallback
RemoteBunkerSigner A NIP-46 connection token + bunker URL + scoped perms (per-user) RPC over relay (kind 24133) to a sidecar bunker Steady state — what every bound user ends up on
ClientSideOnlySigner Nothing — sentinel meaning "user signs in their own client" Not the bot; events for this user come in via subscription from elsewhere Sovereignty escape (NIP-41) — user took ownership
LocalSigner A user's encrypted-at-rest keypair on the bot's host Server-side, in-process Transitional — migration helper only; never the long-term home for a key

The steady-state architecture is the operator-IdP-with-sidecar-bunker pattern. The bot (or any other client of the identity provider) holds zero nsec material; a separate bunker process on the same host (e.g. nak bunker) holds every user's target key and signs on RPC. The bot authenticates to the bunker with a per-user NIP-46 connection token issued at binding time (§7.3), scoped to only the kinds the bot needs to sign. Compromise of the bot leaks the scoped tokens; revoking them at the bunker side is one RPC and doesn't disturb the target key or any other client device the user has authorized.

LocalSigner is included in the abstraction for two narrow reasons: (a) it's how identity providers migrate existing plaintext-nsec rows into the bunker safely, and (b) it lets adopters who don't (yet) run a bunker fall back to encrypt-at-rest in-process signing. Once a bunker is available, the migration drains every LocalSigner row into it and flips them to RemoteBunkerSigner. New accounts MUST NOT default to LocalSigner.

For each captured message the bot:

  1. Looks up the originating chat handle (e.g. MXID) in its binding table (§7.3) — yielding a connection token + bunker URL + pubkey (or None if unbound).
  2. If bound and the signer is server-callable (RemoteBunkerSigner or, transitionally, LocalSigner), signs as the user. Drop the author tag — the pubkey is the attribution.
  3. If unbound — or bound to ClientSideOnlySigner for a verb the user must sign themselves — falls back to BotSigner with the author tag (the v1 §7.1 behavior).

This degrades gracefully: a community can run v1 indefinitely with just BotSigner; users opt into per-user signing individually as they bind their identity.

NIP-46 is the only remote signer protocol this spec recognizes. It's what the Nostr ecosystem has converged on for client-without-nsec flows, works without browser extensions on iOS, and natively supports the per-device scoped tokens that make multi-client identity safe. NIP-26 delegation tokens are explicitly NOT used (the Nostr ecosystem has deprecated NIP-26 and NIP-46 covers the same ground better).

7.3 External-identity binding (v2)

For per-user signing to mean anything, the bot needs a verified mapping from chat handle → identity provider → signer. The binding mechanism is implementation-defined but MUST be:

  • Opt-in per user. No silent association.
  • Verifiable — the binding proves the user controls both identities (e.g. magic-link round-trip via the IdP's authenticated session, signature challenge, NIP-39 external identity proof).
  • Revocable — user can unbind. Bot drops back to BotSigner fallback. Crucially: revocation MUST also revoke whatever per-device signing capability the binding established (see below).

Binding artifact (the important part)

A binding is not just a row in a mapping table — it's a per-device NIP-46 connection token scoped to exactly the kinds the bot needs to sign on behalf of the user. The bot stores:

  • The user's pubkey (for display, addressing, ECDH)
  • The bunker URL (transport: which relay channel the bunker listens on)
  • The connection token (the credential to authenticate to the bunker)
  • The granted permission scope (e.g. sign_event:31922,31923,31925 for a community-organizer bot — no kind-1 notes, no DMs, no profile edits)

Per-device-scoped tokens are the security property that makes multi-client identity safe. If the bot is compromised, the attacker can publish NIP-52 events as the user — annoying, but they can't publish kind-1 notes, change the user's profile, send DMs, or do anything else the user's other devices can. Revocation of the bot's token at the bunker side is one RPC and doesn't disturb the target key or any other authorized device.

Identity-provider options

The spec does not mandate a specific identity provider. Any system that can answer "for chat handle X, issue me a scoped connection token to sign on their behalf" works:

  • A Lightning/Nostr account system (LNbits, Alby Hub, etc.) — the reference implementation. These already hold user pubkeys and have an authenticated session surface that can mediate the binding flow. Per the operator-IdP pattern (§12), the IdP itself doesn't hold the nsec — a sidecar bunker does — and the IdP brokers token issuance to that bunker.
  • A standalone web app with its own auth and a bind chat handle flow that issues NIP-46 connection tokens.
  • An on-Nostr profile claim — user publishes a kind-0 / kind-10002 / NIP-39 attestation linking the chat handle to a pubkey, with a pre-issued bunker URL in their profile. Bot reads relay and cryptographically verifies the claim.

Adopters without any of the above can stay on v1 (BotSigner only) — per-user signing is enhancement, not requirement.


8. Worked examples

8.1 Capture: explicit verb + tag

User types in Matrix room #animals:matrix.example:

!task #buy goat dewormer from the co-op

Bot publishes:

{
  "kind": 31922,
  "pubkey": "<bot-pubkey>",
  "created_at": 1716559200,
  "tags": [
    ["d", "cdf-animals:01HXXJ8K7VP"],
    ["title", "goat dewormer from the co-op"],
    ["t", "task"],
    ["t", "buy"],
    ["t", "src:explicit"],
    ["a", "34550:<owner-pk>:cdf-animals"],
    ["author", "@alice:matrix.example"]
  ],
  "content": "goat dewormer from the co-op"
}

Bot replies: ✅ Task recorded (#buy). React with ❌ to delete.

8.2 Capture: community shortcut

Same room has !buy configured as a shortcut. User types:

!buy door handles from Laura

Bot expands to !task #buy door handles from Laura and publishes the same shape as §8.1 but with ["t", "src:shortcut"].

8.2a Capture: priority + sidequest

!sidequest #priority:3 build a chicken-tractor prototype from the old pallets

Bot publishes:

"tags": [
  ["d", "cdf-animals:01HXXJ9PK2Q"],
  ["title", "build a chicken-tractor prototype from the old pallets"],
  ["t", "sidequest"],
  ["t", "priority:3"],
  ["t", "src:explicit"],
  ["a", "34550:<owner-pk>:cdf-animals"],
  ["author", "@alice:matrix.example"]
]

Bot replies: 🎯 Sidequest recorded (priority: important). React with ❌ to delete.

8.2b Capture: priority shortcut + frequent/ongoing

Room has urgent and chores configured per §3.4 example.

!urgent the alpaca fence is down by the south paddock

Expands to !task #priority:1 the alpaca fence is down… — the bot SHOULD also notify the room (urgency-level escalation hook).

!chores brush the goats

Expands to !task #priority:5 brush the goats. Renderers surface this in the "recurring" view, not the urgent-action queue.

8.3 Capture: freeform with rules classification

User types:

!add need to roll out flypaper for the stable

Bot writes immediately as unclassified, classifier sees "need to" and "flypaper" (matches buy rule), updates tags within 100ms:

"tags": [
  ["t", "task"],
  ["t", "buy"],
  ["t", "src:rules"],
  ...
]

Bot edits its ack: 📥 Logged as a #buy task. React with 🔄 to re-classify.

8.4 Capture: freeform falling through to LLM (Level 2)

User types:

!add the duck babies straw needs scraping clean tomorrow

Rules don't match cleanly (no buy keyword, future-tense not past). Bot writes as unclassified; LLM classifier (running async on a local Ollama model) returns task #animals within 25s. Bot updates tags, edits ack with the result.

8.5 Close

User reacts to the bot's original ack message with , OR types:

!done 17

Bot publishes a kind 31925 event referencing the task's a-tag with status: accepted.

8.6 Journal entry (past-tense)

User types:

!journal
- opened, watered, and fed the hens, alpacas, and chicks
- put insulators on the rebars so they're ready for the fence
- mentally plotted potential hidden-in-plain-sight stable locations

Bot publishes a single kind 31922 with ["t", "journal"], content containing the full multi-line body verbatim.

8.7 Reminder

User types:

!remind tomorrow 9am check sapphi's wound

Bot publishes kind 31923 with start = <tomorrow 9am unix ts>, ["t", "remind"]. At fire time, the bot posts a chat message pinging the originating user.

8.8 Query

User types:

!list task #buy

Bot returns the most recent N open tasks in the current community tagged buy. Implementation-defined formatting.

8.9 Inbound: external publisher

A community member uses the webapp (or any other client) to create a NIP-52 event with the right community a-tag. The bot, which subscribes to events tagged with its communities, mirrors it into its local cache. The next !list includes the externally-created entry. The eink renderer (also subscribed) shows it.


9. Conformance

An implementation is conformant if:

  1. It produces events matching §4 for all universal verbs in §3.1.
  2. It scopes events with NIP-72 a-tags per §5.
  3. It honors NIP-09 deletions (§4.4).
  4. It records classification-source tags (§3.3).
  5. It supports at least classifier Level 0 (§6.1).
  6. It documents its surface vocabulary (universal verbs + any per-community shortcuts) in a form usable by community members.

Bots SHOULD additionally:

  • Subscribe to events tagged with their communities (not just publish) — bidirectional sync makes the spec actually portable.
  • Carry the author tag for human attribution (§4.1).
  • Maintain a local cache for read latency and offline tolerance.

Renderers SHOULD:

  • Filter by a-tag, not by pubkey (so events from any bot OR any external publisher targeting the community are surfaced).
  • Apply NIP-09 deletions before display.
  • Surface a stale-data indicator when relay is unreachable.

10. Extension points

This spec is intentionally minimal. Communities and implementations can extend without breaking conformance via:

  • New domain tags — add whatever t tags make sense locally. Renderers ignore tags they don't recognize.
  • New shortcut verbs — add via per-community config; never modify universal verbs.
  • New classification sourcessrc:<custom> is open.
  • Richer content formatscontent is freeform text in v1; Markdown or other formats are permitted (signal via a format tag) but renderers fall back to plain text.

If you find yourself wanting a new event kind or a new universal verb, please open an issue against this spec — protocol-level additions need broader review.


11. Open questions

Not yet decided in this draft:

  • Recurring reminders. RFC 5545 has RRULE; NIP-52 doesn't formalize recurrence yet. Need a tag convention or wait for an upstream NIP.
  • Cross-community references. A task in #kitchen that depends on a buy entry in #purchasing. Needs convention for e-tags across community boundaries.
  • Assignment. Today author records who captured; we don't formalize "who's assigned". Could use p-tag with a role qualifier.
  • Migration / weekly review. Bullet-journal-style migration of stale open items into a new period. Needs a verb (!migrate?) and a state transition spec.
  • External signer story. Architecture is concrete in §7.2 / §7.3 / §12 (operator-IdP with sidecar nsecbunkerd, per-device scoped connection tokens). Phase 1 of aiolabs/lnbits#9 has shipped the signer abstraction; phase 2 (#18) is the actual bunker integration. Pending design: ergonomics of the chat-side binding flow (DM the bot? web callback? both?), and how to handle ClientSideOnlySigner users whose events can't be bot-published at all (the bot subscribes and mirrors instead).

Contributions welcome on any of these.


12. Reference implementation

The aiolabs reference implementation lives at git.atitlan.io/aiolabs/maubot-plugins/ (the tracker/ plugin) and serves the Château du Faune community at Ariège, France. It targets Matrix as the chat surface and uses NIP-52 + NIP-72 over a self-hosted Nostr relay.

A companion eink renderer at git.atitlan.io/aiolabs/inky-impression/ consumes the same events to drive a 13.3" panel in the community's shared foyer.

Both serve as worked examples of the spec; neither is mandatory.

Alternate runtimes

Adopters who want a richer agent layer than a focused Matrix bot can satisfy this spec on top of a general-purpose agent runtime such as ZeroClaw (Rust, Apache-2.0 / MIT, ships Matrix + Nostr channels and Ollama provider out of the box). The spec is runtime-agnostic — what matters is that captured input produces conformant events per §4 and respects the community scoping in §5. A ZeroClaw-based implementation would carry the ["client", "zeroclaw", "<version>"] tag (per §4.1) instead of ["client", "maubot-tracker", "..."]; renderers ignore the difference since they filter by community a-tag.

Reference identity provider — operator-IdP pattern

The aiolabs reference implementation runs the operator-IdP-with- sidecar-bunker pattern that NIP-46 was designed for. Three processes on the same host:

   ┌─────────────────────────────┐
   │   nsecbunkerd (sidecar)     │     Holds every user's target key
   │                             │     + the operator master.
   │   - admin: M_lnbits         │     Per-device scoped connection
   │   - targets: X_alice, …     │     tokens.
   │   - per-client tokens       │     Speaks NIP-46 over kind-24133;
   │   - scoped perms            │     admin RPC over kind-24134.
   └────────────┬────────────────┘
                │ kind-24133 / 24134 over internal relay
                ▼
   ┌─────────────────────────────┐
   │   LNbits (identity broker)  │     Holds zero nsec material.
   │                             │     Stores per-user (pubkey,
   │   - account → pubkey        │     signer_type, signer_config)
   │   - account → bunker URL    │     where signer_config is the
   │   - admin RPC client        │     bunker URL + connection token
   │     (scoped agent key)      │     + scoped perms.
   └────────────┬────────────────┘
                │ HTTPS (token issuance, account lookup)
                ▼
   ┌─────────────────────────────┐
   │   Bot / tracker plugin /    │     Holds per-user connection
   │   other clients             │     tokens scoped to its needs.
   │                             │     Speaks NIP-46 directly to
   │   - NIP-46 client per user  │     the bunker. Compromise →
   │     for sign_event RPCs     │     revoke just that token.
   └─────────────────────────────┘

This is the architecture from aiolabs/lnbits#9 (the IdP framing and signer abstraction) and aiolabs/lnbits#18 (the concrete bunker integration using nsecbunkerd). PR #17 has shipped phase 1 (the abstraction + transitional LocalSigner + the classify migration); phase 2 (#18) is the actual bunker integration.

Why a sidecar bunker, not "encrypt at rest in the IdP"

The IdP attack surface (web requests, dependency tree, plugin system, admin endpoints, the database) is large. Putting nsec material on the other side of an RPC boundary — held by a much smaller process whose only job is signing — bounds compromise to "we leak signed events during the window the bunker is unavailable for revocation" rather than "the entire user identity pool is exfiltrated". The bunker also gets its own audit log, independent of the IdP's.

The pattern is reusable beyond LNbits — any operator who wants to provide identity-as-a-service to their users can run this same shape with any NIP-46-compliant bunker (nsecbunkerd, Pablo's reference implementation, or any future alternative). The Nostriga 2024 NIP-46 panel describes the same architecture.

Adopters without an IdP

Communities without an existing identity provider can run v1 (BotSigner only) indefinitely. Per-user signing is enhancement, not requirement — the community organizer use case works fine with the bot signing as itself and human attribution carried in the author tag.


Changelog

  • 0.1 (2026-05-24) — initial draft.