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

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:

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.


{
  "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"
}

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).


{
  "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": ""
}

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:


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

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


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

Everything one person signed up for:


{"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:

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 external identity claim to their kind 0:


{"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:


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

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):


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

Each event looks like:


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

Parsing rules:

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)

Using the app

Agent skills

This page: HTML · Markdown · machine index: llms.txt