An event: a photowalk, workshop, meetup, exhibition or talk. Written to the organiser's own PDS. Others RSVP via app.zeens.rsvp (ATProto handle) or via an email address tracked off-protocol in PocketBase. The app does NOT collect payment: paid events carry free-text paymentInstructions and any URL/link in that field is rejected at write time. Carries the app.zeens.v marker so firehose + Lua filters recognise it. NOTE: the record id stays 'app.zeens.event' and the app.zeens.* namespace is network plumbing — it is not renamed when the product is. Two orthogonal axes describe an event: `mode` (WHERE it happens — in person, online, or both) and `categories` (WHAT it is — one or more of photowalk/workshop/meetup/exhibition/talk/other). The `hostedBy` field is DEPRECATED (the organiser is the record owner); kept for back-compat with old records.
tid
Timestamp-based ID
Properties
app.zeens.v
integer
Required
Zeens version marker (always 1).
approvalRequired
boolean
Optional
When true, an RSVP is pending until the organiser approves it; only approved RSVPs count toward the cap and the attendee list. Approval state is tracked off-protocol (the organiser cannot mutate another user's rsvp record). Default false.
capacityMode
string
Optional
Behaviour when maxParticipants is reached. 'hard' = block new going RSVPs. 'waitlist' = overflow goes on a waitlist, promoted in RSVP order. Only meaningful when maxParticipants is set. Default 'hard'.
hard, waitlistcategories
array
of
string
Required
WHAT the event is. One or more (max 3) — an event can be both a photowalk and a meetup. Drives browse/filter, which OR-matches any element. Orthogonal to `mode`. The legacy value 'online_meetup' is tolerated on read and means mode 'online' + category 'meetup'.
maxLength: 3 itemsminLength: 1 itemscoverImage
blob
Optional
Wide cover image shown on the event header and share previews.
maxSize: 4.0 MBcreatedAt
string
datetime
Required
An RFC 3339 formatted timestamp.
createdBy
string
Optional
Display text for the event creator/organiser (the account that owns this record).
maxLength: 200 bytesdescription
string
Optional
Long-form event description.
maxLength: 3000 bytesendsAt
string
datetime
Optional
Optional event end instant.
facets
array
of
ref
app.bsky.richtext.facet
Optional
Rich-text facets (links, mentions) over the description, reusing the Bluesky richtext lexicon.
hostedBy
string
Optional
DEPRECATED. Display text for who is hosting. The organiser is the record owner, so this is no longer written; kept so old records still read.
maxLength: 200 bytesitinerary
array
of
ref
#itineraryItem
Optional
Ordered agenda for the event: a list of items, each with optional time, descriptive text, and an optional location. Works for any event type — a photowalk's route, a workshop's schedule, an exhibition's programme. Items that carry coordinates are drawn on the event map as numbered pins in order; an event whose items have no coordinates simply renders the list with no map.
maxLength: 30 itemslinks
array
of
ref
#link
Optional
Social / website links for the event.
maxLength: 5 itemslocation
ref
#location
Optional
Physical location. Expected for mode 'in_person' and 'hybrid'; absent for 'online'.
maxParticipants
integer
Optional
Optional cap on confirmed (going) participants. Absent = unlimited.
minimum: 1mode
string
Required
WHERE the event happens, and one of exactly three values. 'in_person' needs a location; 'online' needs a virtualUrl and has no location; 'hybrid' needs both. Orthogonal to `categories` — an online workshop is mode 'online' + categories ['workshop']. Records written before this field carried meaning default to 'in_person' on read. The legacy value 'virtual' is tolerated on read and means 'online'.
in_person, online, hybridpaid
boolean
Optional
Whether the event costs money. The app never processes payment.
paymentInstructions
string
Optional
Free-text explanation of how the organiser collects payment out of band. MUST NOT contain a URL/link; rejected at write time client- and server-side.
maxLength: 1000 bytesphotoCategories
array
of
string
Optional
Photography genre tags (Street, Landscape, Portrait, ...). Up to 3, optional. Drawn from the shared category list used by posts. Distinct from `categories`, which is the event type.
maxLength: 3 itemsrsvpPolicy
string
Required
Who may RSVP. 'atmosphere' = any ATProto account. 'photosky' = members only. 'anyone_email' = anyone, including non-ATProto people via an email address. Enforcement is soft (obscurity-level), like collection visibility.
atmosphere, photosky, anyone_emailstartsAt
string
datetime
Required
Event start, as an absolute instant. Render in `timezone`.
timezone
string
Required
IANA timezone name (e.g. 'America/New_York'). Stored separately from startsAt so calendar export (.ics DTSTART;TZID) and the displayed local time are correct.
maxLength: 100 bytestitle
string
Required
Event title.
maxLength: 300 bytesmaxGraphemes: 150 graphemesvirtualUrl
string
uri
Optional
Join link for events with mode 'online' or 'hybrid'. Required for those modes. Shown only to RSVP'd users in the client.
View raw schema
{
"description": "An event: a photowalk, workshop, meetup, exhibition or talk. Written to the organiser's own PDS. Others RSVP via app.zeens.rsvp (ATProto handle) or via an email address tracked off-protocol in PocketBase. The app does NOT collect payment: paid events carry free-text paymentInstructions and any URL/link in that field is rejected at write time. Carries the app.zeens.v marker so firehose + Lua filters recognise it. NOTE: the record id stays 'app.zeens.event' and the app.zeens.* namespace is network plumbing — it is not renamed when the product is. Two orthogonal axes describe an event: `mode` (WHERE it happens — in person, online, or both) and `categories` (WHAT it is — one or more of photowalk/workshop/meetup/exhibition/talk/other). The `hostedBy` field is DEPRECATED (the organiser is the record owner); kept for back-compat with old records.",
"key": "tid",
"record": {
"properties": {
"app.zeens.v": {
"description": "Zeens version marker (always 1).",
"type": "integer"
},
"approvalRequired": {
"description": "When true, an RSVP is pending until the organiser approves it; only approved RSVPs count toward the cap and the attendee list. Approval state is tracked off-protocol (the organiser cannot mutate another user's rsvp record). Default false.",
"type": "boolean"
},
"capacityMode": {
"description": "Behaviour when maxParticipants is reached. 'hard' = block new going RSVPs. 'waitlist' = overflow goes on a waitlist, promoted in RSVP order. Only meaningful when maxParticipants is set. Default 'hard'.",
"knownValues": [
"hard",
"waitlist"
],
"type": "string"
},
"categories": {
"description": "WHAT the event is. One or more (max 3) — an event can be both a photowalk and a meetup. Drives browse/filter, which OR-matches any element. Orthogonal to `mode`. The legacy value 'online_meetup' is tolerated on read and means mode 'online' + category 'meetup'.",
"items": {
"knownValues": [
"photowalk",
"workshop",
"meetup",
"exhibition",
"talk",
"other"
],
"type": "string"
},
"maxLength": 3,
"minLength": 1,
"type": "array"
},
"coverImage": {
"accept": [
"image/jpeg",
"image/png",
"image/webp"
],
"description": "Wide cover image shown on the event header and share previews.",
"maxSize": 4000000,
"type": "blob"
},
"createdAt": {
"format": "datetime",
"type": "string"
},
"createdBy": {
"description": "Display text for the event creator/organiser (the account that owns this record).",
"maxLength": 200,
"type": "string"
},
"description": {
"description": "Long-form event description.",
"maxLength": 3000,
"type": "string"
},
"endsAt": {
"description": "Optional event end instant.",
"format": "datetime",
"type": "string"
},
"facets": {
"description": "Rich-text facets (links, mentions) over the description, reusing the Bluesky richtext lexicon.",
"items": {
"ref": "app.bsky.richtext.facet",
"type": "ref"
},
"type": "array"
},
"hostedBy": {
"description": "DEPRECATED. Display text for who is hosting. The organiser is the record owner, so this is no longer written; kept so old records still read.",
"maxLength": 200,
"type": "string"
},
"itinerary": {
"description": "Ordered agenda for the event: a list of items, each with optional time, descriptive text, and an optional location. Works for any event type — a photowalk's route, a workshop's schedule, an exhibition's programme. Items that carry coordinates are drawn on the event map as numbered pins in order; an event whose items have no coordinates simply renders the list with no map.",
"items": {
"ref": "#itineraryItem",
"type": "ref"
},
"maxLength": 30,
"type": "array"
},
"links": {
"description": "Social / website links for the event.",
"items": {
"ref": "#link",
"type": "ref"
},
"maxLength": 5,
"type": "array"
},
"location": {
"description": "Physical location. Expected for mode 'in_person' and 'hybrid'; absent for 'online'.",
"ref": "#location",
"type": "ref"
},
"maxParticipants": {
"description": "Optional cap on confirmed (going) participants. Absent = unlimited.",
"minimum": 1,
"type": "integer"
},
"mode": {
"description": "WHERE the event happens, and one of exactly three values. 'in_person' needs a location; 'online' needs a virtualUrl and has no location; 'hybrid' needs both. Orthogonal to `categories` — an online workshop is mode 'online' + categories ['workshop']. Records written before this field carried meaning default to 'in_person' on read. The legacy value 'virtual' is tolerated on read and means 'online'.",
"knownValues": [
"in_person",
"online",
"hybrid"
],
"type": "string"
},
"paid": {
"description": "Whether the event costs money. The app never processes payment.",
"type": "boolean"
},
"paymentInstructions": {
"description": "Free-text explanation of how the organiser collects payment out of band. MUST NOT contain a URL/link; rejected at write time client- and server-side.",
"maxLength": 1000,
"type": "string"
},
"photoCategories": {
"description": "Photography genre tags (Street, Landscape, Portrait, ...). Up to 3, optional. Drawn from the shared category list used by posts. Distinct from `categories`, which is the event type.",
"items": {
"maxLength": 100,
"type": "string"
},
"maxLength": 3,
"type": "array"
},
"rsvpPolicy": {
"description": "Who may RSVP. 'atmosphere' = any ATProto account. 'photosky' = members only. 'anyone_email' = anyone, including non-ATProto people via an email address. Enforcement is soft (obscurity-level), like collection visibility.",
"knownValues": [
"atmosphere",
"photosky",
"anyone_email"
],
"type": "string"
},
"startsAt": {
"description": "Event start, as an absolute instant. Render in `timezone`.",
"format": "datetime",
"type": "string"
},
"timezone": {
"description": "IANA timezone name (e.g. 'America/New_York'). Stored separately from startsAt so calendar export (.ics DTSTART;TZID) and the displayed local time are correct.",
"maxLength": 100,
"type": "string"
},
"title": {
"description": "Event title.",
"maxGraphemes": 150,
"maxLength": 300,
"type": "string"
},
"virtualUrl": {
"description": "Join link for events with mode 'online' or 'hybrid'. Required for those modes. Shown only to RSVP'd users in the client.",
"format": "uri",
"type": "string"
}
},
"required": [
"title",
"startsAt",
"timezone",
"mode",
"rsvpPolicy",
"categories",
"createdAt",
"app.zeens.v"
],
"type": "object"
},
"type": "record"
}