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>
917 lines
35 KiB
Markdown
917 lines
35 KiB
Markdown
# 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):
|
||
|
||
```yaml
|
||
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.
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```json
|
||
"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:
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```json
|
||
"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:
|
||
|
||
```json
|
||
"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 2–5s. 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 sources** — `src:<custom>` is open.
|
||
- **Richer content formats** — `content` 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](https://git.atitlan.io/aiolabs/lnbits/issues/9)
|
||
has shipped the signer abstraction; phase 2 ([#18](https://git.atitlan.io/aiolabs/lnbits/issues/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](https://github.com/zeroclaw-labs/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](https://git.atitlan.io/aiolabs/lnbits/issues/9)
|
||
(the IdP framing and signer abstraction) and
|
||
[aiolabs/lnbits#18](https://git.atitlan.io/aiolabs/lnbits/issues/18)
|
||
(the concrete bunker integration using
|
||
[`nsecbunkerd`](https://github.com/kind-0/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.
|