Calendar events in edufeed - NIP-52 plus educational attributes
Von Edufeed ·
Calendar events in edufeed
edufeed's calendar is built on NIP-52: date-based events are kind 31922, time-based events kind 31923, both addressable by kind:pubkey:d-tag. On top of the standard tags, educational events carry a small set of additive attributes that the edufeed working group agreed on in 2026 (registration, cost, attendance mode, Bildungsstufe, participant roles). A NIP-52 client that knows nothing about them still renders the event; an education-aware client can show and filter them.
This article documents what the edufeed app writes and how it reads, so that importers, other clients and the NOPE metadata service interoperate. The write-side specification with the working-group rationale is the Educational Calendar Events integration guide in edufeed-examples; the agreements themselves are in edufeed-app issue #13. The app implements them since release v0.3.3 (2026-10-05).
Base tags (NIP-52)
The app writes these standard tags. content holds the description (Markdown).
| Tag | Kind | Written by edufeed | Notes |
|---|---|---|---|
d |
both | yes | Stable identifier; the app generates one, importers often use the source URL |
title |
both | yes | Required |
start, end |
both | yes | 31922: YYYY-MM-DD, end exclusive. 31923: unix seconds. A new event in the app ends on the day it starts |
start_tzid, end_tzid |
31923 |
yes | IANA zone names, e.g. Europe/Berlin |
D |
31923 |
yes | Day-granularity timestamps (floor(unix / 86400)), one per day the event covers |
summary |
both | no | Read as a fallback when content is empty; the app edits content |
image |
both | yes | Preview image URL. Uploads go through the app's Blossom server with a license event (see License events for uploaded files) |
location |
both | yes | Address, room, or for online events the meeting link (see below) |
g |
both | no | Geohash is read, never written |
t |
both | yes | Hashtags, repeated |
r |
both | yes | Links, repeated. The app shows the first r tag as the event website button and the rest under "Weitere Links", so put the event page first |
p |
both | yes | Participants with an optional relay hint and role (see below) |
h |
both | yes | Community targeting, one tag per community (Communikey, see Sharing and reading community content in edufeed) |
client |
both | yes | Edufeed, when the user opted in |
Calendars (kind 31924) and RSVPs (kind 31925) follow NIP-52 unchanged. The RSVP is labelled "Teilnahme anzeigen" in the app: it is a public statement of intent, not a registration with the organizer.
Educational attributes
All four attributes are optional. An absent tag means "no statement"; the app's form offers "Keine Angabe" for each one and then writes nothing. The app reads the first tag of each name and ignores later duplicates.
Registration
["registrationRequired", "true"]
["registrationRequired", "false"]
Only the literal strings true and false count; any other value is ignored. Where and how to register (links, deadlines) goes into the description or an r tag. There is deliberately no structured registration-link field.
Cost
["price", "<amount>", "<ISO-4217 currency>"]
| Value | Meaning |
|---|---|
| tag absent, or amount not a number | no statement |
amount 0 |
free (kostenlos) |
amount > 0 |
paid (kostenpflichtig), shown as "Kostenpflichtig: 25 EUR" |
The amount is a non-negative decimal (0, 25, 12.50); the app also accepts a decimal comma on input and normalises it to a dot. The currency is upper-cased on read; the app's form defaults to EUR. The shape follows the NIP-99 price tag, the semantics NIP-15. Reduced or tiered prices have no structured field: describe them in the text. If no single number is honest, leave the tag out.
Attendance mode
["eventAttendanceMode", "https://schema.org/OnlineEventAttendanceMode"]
["eventAttendanceMode", "https://schema.org/OfflineEventAttendanceMode"]
["eventAttendanceMode", "https://schema.org/MixedEventAttendanceMode"]
Name and values are taken from schema.org. The app labels them Online, Präsenz and Hybrid; "Mixed" covers what other systems call blended or hybrid. On read the app tolerates http:// and a bare term (OnlineEventAttendanceMode), but always writes the full https://schema.org/ URI.
For online events the location tag is the meeting link or just online; the app labels the field "Online-Ort" and shows no map. Hybrid and in-person events keep the address and the map.
Bildungsstufe (educational level)
The stage of the education system the event addresses, as a concept of the KIM educationalLevel vocabulary (https://w3id.org/kim/educationalLevel/). It is written with the NIP-AMB flattening convention also used on kind 30142 learning resources: one concept triple per value, repeated for several values.
["educationalLevel:id", "https://w3id.org/kim/educationalLevel/level_C"]
["educationalLevel:prefLabel:de", "Fortbildung"]
["educationalLevel:prefLabel:en", "Advanced training"]
["educationalLevel:type", "Concept"]
Reading rules:
-
The
:idURI is authoritative.:prefLabel:<lang>tags are display copies; they belong to the preceding:idtag, so keep each triple contiguous. The app shows the label in the UI language and falls back to the URI's last path segment. - A concept repeated twice is read once.
-
The app writes every label the vocabulary provides, German first, then
["educationalLevel:type", "Concept"].
The available concepts (level_0 Elementarbereich ... level_A Hochschule, level_B Vorbereitungsdienst, level_C Fortbildung) are listed in the integration guide and published as a SKOS scheme (kind 39737) that the app loads from its configured vocabulary relay; the same scheme feeds the Bildungsstufe field of the learning-resource form.
Participants and roles
NIP-52 participants are p tags with an optional relay hint and a free-text role in the fourth slot:
["p", "<pubkey hex>", "<relay hint or empty>", "<role>"]
The app offers these role presets and shows them translated (unknown roles are shown as written):
| Role | German label |
|---|---|
participant |
Teilnehmer:in |
attendee |
Besucher:in |
speaker |
Vortragende:r |
organizer |
Organisator:in |
moderator |
Moderator:in |
organizer and attendee are the pair agreed in issue #13. An attendee p tag is set by the event author and is independent of RSVPs (kind 31925), which attendees publish themselves.
Named participants without an npub. Most real-world speakers have no Nostr key. edufeed stores them in an app-specific tag with the same slot layout as p, so that parsers stay symmetric:
["participant", "Jane Doe", "", "speaker"]
["participant", "Jane Doe"]
Slot 2 is always empty (there is no relay for a name). The app merges p and participant tags into one participant list, deduplicated by pubkey or name. Other clients can safely ignore the tag.
Editing by edufeed
When the app edits an event it did not create (or one created by an older version), it only replaces the tags its form manages: d, h, title, start, end, start_tzid, end_tzid, D, image, location, t, r, p, participant, registrationRequired, price, eventAttendanceMode, educationalLevel:* and client. Every other tag of the existing event is kept in its original order, so an importer's extra tags (summary, g, a calendar references, source identifiers) survive an edit in edufeed.
Updates re-publish the full event under the same d tag. Deletion is NIP-09: a kind 5 with an a tag 3192x:<pubkey>:<d>.
Worked example
A live event on the edufeed relay (meeting link shortened), as the app writes it:
{
"kind": 31923,
"content": "<description>",
"tags": [
["d", "event-1791358334624-li9ls1k11"],
["h", "bdc21f93b1e2cb75608cecd7a0a00a779779d9367dc9798bd9f213f06c95bc48"],
["title", "Edufeed Community Call"],
["start", "1791363600"],
["end", "1791367200"],
["start_tzid", "Europe/Berlin"],
["end_tzid", "Europe/Berlin"],
["D", "20733"],
["image", "https://image.nostr.build/1785713d....jpg"],
["location", "https://edufeed.org/call/..."],
["t", "community"],
["t", "edufeed"],
["p", "4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41", "wss://relay.plebstr.com", "participant"],
["registrationRequired", "false"],
["price", "0", "EUR"],
["eventAttendanceMode", "https://schema.org/OnlineEventAttendanceMode"],
["educationalLevel:id", "https://w3id.org/kim/educationalLevel/level_A"],
["educationalLevel:prefLabel:de", "Hochschule"],
["educationalLevel:prefLabel:en", "University"],
["educationalLevel:type", "Concept"],
["educationalLevel:id", "https://w3id.org/kim/educationalLevel/level_C"],
["educationalLevel:prefLabel:de", "Fortbildung"],
["educationalLevel:prefLabel:en", "Advanced training"],
["educationalLevel:type", "Concept"],
["client", "Edufeed"]
]
}
The app renders the attributes as badges on the event page: "Ohne Anmeldung", "Kostenlos", "Online", and "Bildungsstufe: Hochschule, Fortbildung". Because the mode is online, the location is shown as a link and no map is drawn.
Relays and queries
edufeed publishes calendar events to its calendar relays (wss://relay.edufeed.org and the deployment's configured calendar relays) plus the author's NIP-65 write relays and, for h-tagged events, the community's relays. Fetch with a plain NIP-01 filter and match the extension tags client-side:
{"kinds": [31922, 31923], "limit": 500}
since/until match created_at, not start, so "upcoming" is a client-side filter too. Relay-side filters on the multi-character tag names (#educationalLevel:id) are an edufeed relay extension that the calendar relay does not index yet.
Open follow-ups
- Filters by registration, cost, mode and Bildungsstufe in the app's calendar views.
- Indexing of the new tag names at the calendar relay.
-
Further vocabulary attributes agreed as "follow up" in issue #13 (Bildungsbereiche, Zielgruppen), to be flattened the same way as
educationalLevel. - Structured per-audience pricing was discussed and deferred; the description text is the interim answer.
Source of the rules: src/lib/helpers/calendar-attributes.js, calendar.js (buildCalendarEventTags, CALENDAR_FORM_OWNED_TAGS) and eventUtils.js (getCalendarEventMetadata) in edufeed-app.