Calendar events from forms in edufeed - NIP-101 templates that compose NIP-52 events
Von Edufeed ·
Calendar events from forms in edufeed
edufeed lets people propose and publish calendar events through a Nostr form instead of an app-specific input screen. The form is a NIP-101 template (kind 30168). A filled-in form is a NIP-101 response (kind 1069). A reviewer, or the author directly, turns the response into a NIP-52 calendar event with the educational attributes described in Calendar events in edufeed.
Because the form is an event, every client renders the same questions: the edufeed app, the edufeed WordPress plugins, or any other NIP-101 client. A plain NIP-101 client can still show the form and collect responses. It just won't compose the calendar event.
This article builds on edufeed's NIP-101 profile, NIP-101-EDU. That profile defines the base encoding, field-vocab bindings to SKOS vocabularies (NIP-VOCAB, kinds 39737/39738) and the field-output tag. This article adds one output target: NIP-52 calendar events.
Roles and flow
shared template (edufeed) ──forkOf──▶ community copy (community key)
│
visitor / member fills it in
▼
response (kind 1069, encrypted to community key)
│
reviewer edits and publishes
▼
calendar event (kind 31922 / 31923)
- Shared template. edufeed publishes one calendar form template. It is the reference every client and community uses.
- Community copy. NIP-101 addresses each response to the template's author. A community that wants submissions publishes an unchanged copy of the shared template under its own key (see Community copies). Responses to the copy go to the community.
- Response. Anyone fills in the copy. The response is encrypted to the community key, as for any non-public NIP-101-EDU form.
- Publishing. Someone holding the community key decrypts the response, reviews and edits it, and publishes the calendar event. Clients may also skip the response and compose the event directly from the form when the user can sign it (a logged-in editor, for example).
The calendar output target
A template whose responses compose a calendar event declares it in its settings JSON:
{"output": "nip52-calendar"}
Each field that feeds the event carries a field-output tag with a cal: target:
["field-output", "<fieldId>", "cal:<target>"]
ext (NIP-101-EDU) is not used for calendar output. A field without a cal: target does not reach the event, though it stays in the response for the reviewer to read.
Targets
| Target | Field type (renderElement) |
Answer in the response | Emitted on the event |
|---|---|---|---|
cal:title |
text, required |
the title | ["title", …] |
cal:content |
textarea |
Markdown description | event content |
cal:allDay |
checkbox (boolean) |
true or false |
kind 31922 if true, else 31923 |
cal:start |
datetime |
see Dates and times | start, and D for 31923 |
cal:end |
datetime |
see Dates and times | end, and D for 31923 |
cal:tzid |
text, default Europe/Berlin |
IANA zone name | start_tzid and end_tzid (31923 only) |
cal:location |
text |
address, room, meeting link or online |
["location", …] |
cal:image |
url |
image URL | ["image", …] |
cal:t |
text-array |
hashtags | one ["t", …] each, without a leading # |
cal:r |
text-array |
URLs, event page first | one ["r", …] each, in order |
cal:participant |
text-array |
names or npubs | p or participant tags, see Participants |
cal:registrationRequired |
radio with options true, false |
true or false, or no answer |
["registrationRequired", …] |
cal:price |
number |
non-negative decimal, comma accepted | ["price", <amount>, <currency>] |
cal:currency |
text, default EUR |
ISO 4217 code | currency slot of price |
cal:eventAttendanceMode |
radio |
the full schema.org URI (option id) | ["eventAttendanceMode", <uri>] |
cal:educationalLevel |
vocabulary-bound (field-vocab) |
concept URIs | concept triples, see Bildungsstufe |
Rules that apply to every target:
-
No answer means no statement. An empty or missing answer emits no tag, exactly as the calendar article's "Keine Angabe". This holds in particular for
registrationRequired,price,eventAttendanceModeandeducationalLevel. -
Values follow the calendar article. The composer applies the calendar article's write rules, not its own. For example,
priceis normalised to a dot decimal and an upper-case currency. A price answer that is not a number emits nopricetag. - One field per target. If a template has two fields with the same target, the first one in tag order wins.
-
The composer owns the rest.
d,h,clientandDnever come from a field. The publisher setsdandh(see Publishing) and derivesDfrom start and end.
Field types and answers
Field types, list answers and their ; encoding follow NIP-101-EDU. The datetime type and the allDayField setting used below are defined there.
Dates and times
-
All-day (
31922).cal:startandcal:endanswers areYYYY-MM-DD. The end answer is the last day of the event, as a person enters it. NIP-52 definesendas exclusive and requiresstartto be less thanend. So the composer writes noendfor a one-day event (empty end answer, or end equal to start). For a longer event it writes the last day plus one. Mapping an event back onto the form subtracts that day again. -
Timed (
31923). Answers are local wall-clock timesYYYY-MM-DDTHH:MM, interpreted in thecal:tzidzone. The composer converts them to unix seconds forstartandend. It writesstart_tzidandend_tzid, and oneDtag per day covered (floor(unix / 86400)). An empty end emits noendtag. - An end before the start is invalid. The composer rejects the response rather than guessing.
Participants
Each entry of a cal:participant answer is either a person with a Nostr key or a named person:
-
An
npub1…,nprofile1…or 64-character hex entry becomes["p", <hex>, <relay hint or "">, <role>]. -
Any other entry becomes
["participant", <name>, "", <role>].
The role comes from the field's settings, "participantRole": "<role>", using the calendar article's roles (participant, attendee, speaker, organizer, moderator). Without it, the role slot is left out. A template asks for speakers and organizers with two fields, each with its own role. Duplicates by pubkey or by name are emitted once.
Bildungsstufe
cal:educationalLevel is bound with field-vocab to the KIM Bildungsstufe scheme:
["field-vocab", "<fieldId>", "a", "39737:d2689e2f41dabfba953da26655a94ce2aa4e029c383ee921c6a4deafab99a612:educational-level", "wss://relay.edufeed.org"]
The answer holds the concepts' external URIs, the i tag of each kind 39738 concept (e.g. https://w3id.org/kim/educationalLevel/level_C). For each URI, the composer emits the calendar article's concept triple. Labels are taken from the concept's prefLabel tags, German first, then ["educationalLevel:type", "Concept"]. If the concept event cannot be fetched, the composer emits the :id tag and the :type tag without labels; readers fall back to the URI.
Responses without a Nostr key
Visitors without a key respond with a one-time key, as NIP-101-EDU describes under "Responders without a Nostr key". Hosting sites protect that path against spam themselves.
Community copies
A community copy is a kind 30168 event under the community's key that repeats the shared template:
-
the same
dtag as the shared template; -
the same
name,settings,field,field-vocabandfield-outputtags, in the same order; - one fork tag pointing at the shared template, as in NIP-101-EDU:
["a", "30168:<sharedAuthorPubkey>:<d>", "<relay>", "forkOf"]
A client can show a community's form status from these rules alone:
| Status | Condition |
|---|---|
| not published | no 30168 with this d tag by the community key |
| current | the copy exists, and its name, settings, field, field-vocab and field-output tags equal the shared template's |
| outdated | the copy exists, but those tags differ from the latest shared template |
| partially available | the copy is missing on some of the community's relays |
The community key does not have to be the key of a kind 10222 community definition. Which key receives a community's submissions is that community's choice.
Publishing
The event composed from a response, or directly from the form, is published as the calendar article describes:
-
d. A new event gets a fresh identifier. Publishing a response does not reuse the response id asd. -
h. The publisher adds onehtag per community the event belongs to. This is configuration of the publishing site or app, not a form answer. - Signer. The event is signed by whoever publishes it: the community key for reviewed submissions, or the user's own signer.
-
Relays. The event goes to the calendar relays, the signer's NIP-65 write relays, and the relays of every
hcommunity. - Edits and deletion follow the calendar article: replace only the form-managed tags and keep all others, or delete with NIP-09. To edit an event in the form, a client maps the event back onto the fields with the same targets.
A published response is not changed or deleted. Clients keep track of which responses they have handled. They may link a response to the event coordinate it produced.
Worked example
Community copy (fields shortened):
{
"kind": 30168,
"pubkey": "<community>",
"tags": [
["d", "edufeed-calendar-event"],
["name", "Veranstaltung vorschlagen"],
["settings", "{\"output\":\"nip52-calendar\",\"confirmationMessage\":\"Danke! Wir prüfen den Vorschlag.\"}"],
["a", "30168:bdc21f93b1e2cb75608cecd7a0a00a779779d9367dc9798bd9f213f06c95bc48:edufeed-calendar-event", "wss://relay.edufeed.org", "forkOf"],
["field", "title", "text", "Titel", "[]", "{\"renderElement\":\"text\",\"required\":true}"],
["field", "allday", "text", "Ganztägig", "[]", "{\"renderElement\":\"checkbox\"}"],
["field", "start", "text", "Beginn", "[]", "{\"renderElement\":\"datetime\",\"required\":true,\"allDayField\":\"allday\"}"],
["field", "end", "text", "Ende", "[]", "{\"renderElement\":\"datetime\",\"allDayField\":\"allday\"}"],
["field", "mode", "option", "Format", "[[\"https://schema.org/OnlineEventAttendanceMode\",\"Online\"],[\"https://schema.org/OfflineEventAttendanceMode\",\"Präsenz\"],[\"https://schema.org/MixedEventAttendanceMode\",\"Hybrid\"]]", "{\"renderElement\":\"radio\"}"],
["field", "level", "text", "Bildungsstufe", "[]", "{\"renderElement\":\"select\",\"multiple\":true}"],
["field", "speakers", "text", "Vortragende", "[]", "{\"renderElement\":\"text-array\",\"participantRole\":\"speaker\"}"],
["field-vocab", "level", "a", "39737:d2689e2f41dabfba953da26655a94ce2aa4e029c383ee921c6a4deafab99a612:educational-level", "wss://relay.edufeed.org"],
["field-output", "title", "cal:title"],
["field-output", "allday", "cal:allDay"],
["field-output", "start", "cal:start"],
["field-output", "end", "cal:end"],
["field-output", "mode", "cal:eventAttendanceMode"],
["field-output", "level", "cal:educationalLevel"],
["field-output", "speakers", "cal:participant"]
],
"content": ""
}
Decrypted response tags from an anonymous visitor:
[
["response", "title", "OER-Werkstatt", "{}"],
["response", "allday", "false", "{}"],
["response", "start", "2026-11-20T14:00", "{}"],
["response", "end", "2026-11-20T16:00", "{}"],
["response", "mode", "https://schema.org/OnlineEventAttendanceMode", "{}"],
["response", "level", "https://w3id.org/kim/educationalLevel/level_C", "{}"],
["response", "speakers", "Jane Doe", "{}"]
]
Calendar event published by the community after review (no time zone field, so the default Europe/Berlin applies):
{
"kind": 31923,
"content": "",
"tags": [
["d", "<fresh id>"],
["h", "<community>"],
["title", "OER-Werkstatt"],
["start", "1795179600"],
["end", "1795186800"],
["start_tzid", "Europe/Berlin"],
["end_tzid", "Europe/Berlin"],
["D", "20777"],
["participant", "Jane Doe", "", "speaker"],
["eventAttendanceMode", "https://schema.org/OnlineEventAttendanceMode"],
["educationalLevel:id", "https://w3id.org/kim/educationalLevel/level_C"],
["educationalLevel:prefLabel:de", "Fortbildung"],
["educationalLevel:prefLabel:en", "Advanced training"],
["educationalLevel:type", "Concept"]
]
}
Known deviations in edufeed-app
Two edufeed-app behaviours currently differ from the NIPs this article follows. Both are tracked as issues. Readers of existing events and responses should know about them:
-
Inclusive all-day end. The app writes the last day of a
31922event asend, where NIP-52 wants the day after. Tracked in nostr:nevent1qqs9xn4gersnaqhunzuqy568atyuvq2dy2g8qhlwlj3ufra9nd9v2kgpz3mhxue69uhhyetvv9ujumn8d96zuer9wc3uvnyp -
List answers. The app's generic response route does not yet join list answers with
;as NIP-101-EDU requires. Tracked in nostr:nevent1qqsrkcphvta7vazrexp64720vwmdq9wkh0cm33yhncvvrx033r9qfzgpz3mhxue69uhhyetvv9ujumn8d96zuer9wc9ncgpl
Shared template
The shared calendar form is published by the edufeed account (bdc21f93b1e2cb75608cecd7a0a00a779779d9367dc9798bd9f213f06c95bc48) with d = edufeed-calendar-event, on wss://relay.edufeed.org. Every community copy points to this coordinate, so it does not move.
Implementation tracked in edufeed-app: nostr:nevent1qvzqqqqx25pzq2xhe2wt5rjq7kvy8ngu65rur2gjjxtknazftg8n3hetp2cz8z7yqy2hwumn8ghj7un9d3shjtnwva5hgtnyv4mz7qgnwaehxw309ankjarwdaehgu3wvdhk6tcqyrvemkj2868x6cw74myxdqva4fhxyjzk7d9gxd4hadwrpld6dvs42uaaj99