# Elinor — protocol documentation

Elinor is a Telegram bot + Mini App that coordinates recurring community shifts
(cooking, cleaning, …) for the Valley of the Commons. **All shift data lives on
a public Nostr relay as standard NIP-52 calendar events** — the bot is just one
client. Any agent can read the schedule, watch changes in real time, and sign
people up by publishing ordinary Nostr events. This page documents everything
needed to interoperate.

## Connecting

- **Relay:** `wss://relay.commonshub.dev` (strfry; open reads, open writes)
- **Coordinator (bot) pubkey:** `3f432836bece7b0a06dcbaef023f113fdcb10f96fbf98f35dd2e3b3a3c0e2dcb`
  (`npub18apjsd47eeas5pkuhthsy0c38lwtzrukl0uc7dwa9can50qw9h9s0n2qfd`)
- **Timezone:** all shift times are local Europe/Brussels; event timestamps are unix UTC.
- **Location:** Valley of the Commons

## Data model

Two event kinds, both **addressable** (NIP-01 kinds 30000–39999: for a given
`(kind, pubkey, d-tag)` the relay keeps only the newest event — publishing a
new version replaces the old one).

Every event identifier embeds a **group id** (the Telegram chat id of a
community, a negative integer like `-5343036390`): each group is a fully
independent calendar on the same relay.

### Group identity tags

The group id also travels as standard tags, so you never have to parse `d`:

- `["i", "telegram:<groupId>"]` + `["k", "telegram"]` — NIP-73 external
  content id, using NIP-39's `telegram:` namespace. On **every** occurrence
  and RSVP. Filter a group with `{"#i": ["telegram:<groupId>"]}`.
- `["a", "34550:3f432836bece7b0a06dcbaef023f113fdcb10f96fbf98f35dd2e3b3a3c0e2dcb:tg<groupId>"]` — on occurrences only: the
  group's **NIP-72 community definition** (kind 34550, signed by the
  coordinator, `d = tg<groupId>`), carrying the group's name, description,
  the same `i`/`k` tags, the coordinator as moderator and the relay.
  Fetch it with `{"kinds":[34550],"authors":["3f432836bece7b0a06dcbaef023f113fdcb10f96fbf98f35dd2e3b3a3c0e2dcb"]}` to list the
  communities on this relay, or `{"kinds":[34550],"#i":["telegram:<groupId>"]}`
  for one group.

Both are additive: the `d` formats below are unchanged. When you publish,
include the `i`/`k` pair; when you read an RSVP, take the `a` tag that
starts with `31923:` — don't assume it's the first (or only) `a` tag.

### Kind 31923 — shift occurrence (NIP-52 time-based calendar event)

One event per shift, per day, per group. Signed by the **coordinator pubkey**.

```json
{
  "kind": 31923,
  "pubkey": "3f432836bece7b0a06dcbaef023f113fdcb10f96fbf98f35dd2e3b3a3c0e2dcb",
  "tags": [
    ["d", "shift-<groupId>-<YYYY-MM-DD>-<code>"],
    ["title", "Lunch Preparation"],
    ["start", "1782376200"],
    ["end", "1782383400"],
    ["start_tzid", "Europe/Brussels"],
    ["location", "Valley of the Commons"],
    ["capacity", "2"],
    ["t", "shift"],
    ["t", "<code>"],
    ["t", "group-<groupId>"],
    ["i", "telegram:<groupId>"],
    ["k", "telegram"],
    ["a", "34550:3f432836bece7b0a06dcbaef023f113fdcb10f96fbf98f35dd2e3b3a3c0e2dcb:tg<groupId>", "wss://relay.commonshub.dev"]
  ],
  "content": "Lunch Preparation shift, 10:30–12:30 (Valley of the Commons) — optional description"
}
```

- `d` — `shift-<groupId>-<date>-<code>`; `code` is a short slug per shift
  type, `[a-z0-9]{1,16}` (defaults: `mc` Morning Cleaning, `lp` Lunch Preparation, `lc` Lunch Cleaning, `dp` Dinner Preparation, `dc` Dinner Cleaning).
- `start`/`end` — unix seconds (UTC).
- `capacity` — how many people the shift needs. Not enforced by the relay;
  writers must check it (see Writing).
- Occurrences are (re)published daily ~14 days ahead. Editing a shift's
  definition republishes future occurrences with the same `d` tags.

### Kind 31925 — signup (NIP-52 RSVP)

One event per person, per occurrence. Signed by the **participant's key** (the
bot derives a keypair per Telegram user; external agents use their own key).

```json
{
  "kind": 31925,
  "pubkey": "<participant pubkey>",
  "tags": [
    ["a", "31923:3f432836bece7b0a06dcbaef023f113fdcb10f96fbf98f35dd2e3b3a3c0e2dcb:shift-<groupId>-<date>-<code>"],
    ["d", "rsvp-<groupId>-<date>-<code>"],
    ["status", "accepted"],
    ["t", "shift"],
    ["i", "telegram:<groupId>"],
    ["k", "telegram"],
    ["p", "<actor pubkey>", "", "changed-by"]
  ],
  "content": ""
}
```

- `status` — `accepted` (signed up) or `declined` (canceled). Canceling is
  republishing the same `d` with `declined` — never deletion, so history and
  attribution survive.
- `["p", <pubkey>, "", "changed-by"]` — optional; present when someone made
  the change on the participant's behalf (anyone may — transparency, not
  permissions, is the governance model here).

### Resolution rule

For any `(author, a)` pair, **the event with the highest `created_at` wins**;
ties break to the lexically smallest `id`. A participant is on a shift iff
their latest RSVP for it has `status=accepted`.

## Reading

Who is signed up for one shift:

```json
{"kinds": [31925], "#a": ["31923:3f432836bece7b0a06dcbaef023f113fdcb10f96fbf98f35dd2e3b3a3c0e2dcb:shift-<groupId>-<date>-<code>"]}
```

All shift occurrences of a group (next/past days):

```json
{"kinds": [31923], "authors": ["3f432836bece7b0a06dcbaef023f113fdcb10f96fbf98f35dd2e3b3a3c0e2dcb"], "#t": ["group-<groupId>"]}
```

Everything one person signed up for:

```json
{"kinds": [31925], "authors": ["<participant pubkey>"], "#t": ["shift"]}
```

Watch live changes: keep a subscription open on
`{"kinds": [31923, 31925]}` (optionally filtered by `#t`: `group-<groupId>`
for occurrences, or `#a` prefixes for RSVPs).

Discover groups: query the occurrences filter above without `#t` and collect
the distinct `group-*` hashtags.

## Writing

To sign someone up (or yourself — agents are welcome):

1. Read the occurrence (kind 31923) to get `capacity`, and the current RSVPs
   for its address; count `accepted` after applying the resolution rule.
2. If there is room, publish a kind 31925 as above with `status=accepted`.
3. **`created_at` must be strictly greater** than your previous RSVP for the
   same `d`, or the relay rejects it with "replaced: have newer event".
4. To cancel, republish with `status=declined`.

Caveats:

- Capacity is cooperative — the relay does not enforce it. Check before you
  write, and expect races to be rare but possible.
- Do not publish kind 31923 under your own key — occurrences are only
  authoritative from the coordinator pubkey above.

## Identity: linking your users to Telegram members

Elinor watches the relay live: RSVPs published by **any** key are picked up,
shown in the schedule, counted against capacity, and announced in the
community's Telegram group. Give your users a kind 0 profile (a `name` at
minimum) so they appear with a human name instead of a truncated npub.

If your app knows the user's Telegram identity, add a
[NIP-39](https://github.com/nostr-protocol/nips/blob/master/39.md) external
identity claim to their kind 0:

```json
{"kind": 0, "tags": [["i", "telegram:<telegram_user_id>"]], "content": "{\"name\": \"…\"}"}
```

When Elinor first sees an RSVP from a key carrying such a claim, it asks that
Telegram member to confirm the link with one tap. Once confirmed, the key is
theirs everywhere: their name shows on signups, reminders mention them, shifts
signed up in your app appear in their Telegram "my shifts" and personal
calendar, and cancels work across apps.

### Identity providers: automatic linking (kind 31926)

Any app can act as an identity provider — **no registration needed**. Give
your app its own keypair; when a user signs in with Telegram, publish (and
thereafter maintain) one **identity attestation** per user:

```json
{
  "kind": 31926,
  "pubkey": "<the app's pubkey>",
  "tags": [
    ["d", "telegram:<telegram_user_id>"],
    ["p", "<user pubkey hex>"]
  ],
  "content": "{\"name\": \"<display name>\"}"
}
```

- Addressable: republishing with the same `d` replaces the previous list —
  include **all** of the user's current keys as `p` tags; omit a key to
  unlink it.
- Elinor honors attestations from any provider by default and notifies the
  member, who can always unlink a key. Guardrails: an attestation cannot claim
  a coordinator-derived key, cannot re-map a key already linked to a different
  Telegram user, and a provider can only remove links it created itself.
- Governance is a **blacklist**: a provider that misbehaves is blocked by the
  community and every link it created is purged. The attestation trail is
  public, so anyone can audit who asserted what.
- **Guests**: a member without a Telegram account (yet) can be created in the
  app; they get a coordinator-managed key and appear in the directory under
  `d = guest:<id>` instead of `telegram:<id>`. When the person joins
  Telegram, the guest key is linked to their account — person-level resolution
  transfers their entire shift history, and the directory entry moves under
  their `telegram:<id>`.
### Following the member directory (npub ↔ telegram map)

Elinor publishes the full member directory under the coordinator key — one
kind 31926 attestation per member, republished whenever their key set or name
changes. To build and maintain a complete `telegram_user_id → [npubs]` map,
open one subscription (it returns the stored events first, then streams
updates):

```json
{"kinds": [31926], "authors": ["3f432836bece7b0a06dcbaef023f113fdcb10f96fbf98f35dd2e3b3a3c0e2dcb"]}
```

Each event looks like:

```json
{
  "kind": 31926,
  "pubkey": "3f432836bece7b0a06dcbaef023f113fdcb10f96fbf98f35dd2e3b3a3c0e2dcb",
  "tags": [
    ["d", "telegram:849888126"],
    ["p", "<derived key hex>"],
    ["p", "<verified linked key hex>"]
  ],
  "content": "{\"name\": \"Xavier\"}"
}
```

Parsing rules:

- The map key is the `d` tag: `telegram:<user_id>`, or `guest:<id>` for
  members who are not on Telegram yet (see Guests above — when they join,
  their keys move to a `telegram:<id>` entry).
- The person's current keys are the `p` tags — **all of them** belong to the
  same person. The first is usually the coordinator-derived key; the rest are
  verified links from other apps.
- Addressable semantics: keep only the **newest event per `d` tag**
  (highest `created_at`). An update fully replaces the previous key list;
  an event with no `p` tags means the entry was cleared.
- `content` is JSON with the member's display `name`.
- Events are mirrored to any relays listed in the community settings, but
  `wss://relay.commonshub.dev` is canonical.

You can also consume other identity providers' 31926 attestations directly
(same format, their own `d` scoping), but Elinor's directory is the merged,
verified view — if you only follow one feed, follow this one.

### Person-level resolution

One person may act through several keys (the coordinator-derived key plus any
linked app keys). The canonical status of a person on a shift is decided by
the **newest RSVP across all of that person's keys** — so a signup in one app
can be canceled from another. When you publish, use a `created_at` strictly
greater than any of that person's existing RSVPs for the shift, or your event
will lose the comparison (or be rejected by the relay for the same `d` tag).

## Calendar feeds (read-only convenience)

- Per group: `https://elinor.commonshub.dev/calendar/g/<token>.ics` (token shared in the group)
- Per person: `https://elinor.commonshub.dev/calendar/u/<token>.ics`
- Single occurrence: `https://elinor.commonshub.dev/calendar/event/<d-tag>.ics`

## Using the app

- Telegram Mini App: https://t.me/ElinorOstromBot/shifts
- Add the bot to a Telegram group: https://t.me/ElinorOstromBot — it seeds the
  default shift catalog and posts an intro; `/shifts` shows the day's schedule.

## Agent skills

- [whiteboard-sync](https://elinor.commonshub.dev/skills/whiteboard-sync.md) — reconcile a photo of the physical shift whiteboard with the app, both directions (uses the on-behalf-of RSVP flow below).

*This page: [HTML](https://elinor.commonshub.dev/docs) · [Markdown](https://elinor.commonshub.dev/docs.md) · machine index: [llms.txt](https://elinor.commonshub.dev/llms.txt)*
