MenuExample: events page

Example: a group's events page

Everything from the previous guides in one flow. Set up a group calendar, list events with who's going, RSVP, and post an event as the group.

This walks through the events pages of a group’s website. Each step links to the guide that explains it.

The pieces

  • Space: at://<group>/space/fyi.opensocial.events/self, readable by members.
  • Events: community.lexicon.calendar.event records, written by the group into its own repo in that space.
  • RSVPs: community.lexicon.calendar.rsvp records, written by each member into their own repo in the same space, on their own PDS.
  • Scope: atproto, include:fyi.opensocial.basePermissions?aud=…, include:fyi.opensocial.calendarPermissions, plus an rpc:…?aud=… entry for each host method the page calls: requestJoin, getJoinRequest, cancelJoinRequest, getGroupAuth, and createSpace if it sets up the calendar. listGroups needs none. See Signing in.

1. Make sure the calendar exists

Read the group’s space index: fyi.opensocial.space records in its members space (Reading a space). If none has type: "fyi.opensocial.events", an admin creates it with createSpace (Creating a space):

{
  "group": "did:plc:…",
  "type": "fyi.opensocial.events",
  "skey": "self",
  "name": "Events",
  "readableBy": ["member"],
  "credentialScopes": [{ "role": "admin", "scopes": ["community.lexicon.calendar.event"] }]
}

readableBy: ["member"] makes the calendar members-only. credentialScopes lets admins post events as the group. Members can write RSVPs through their own permission set.

2. List events and who’s going

With a credential for the events space:

  1. listRepos on the group’s host gives every writer: the group, plus each member who has RSVP’d.
  2. From the host, listRecords for the group’s repo, collection community.lexicon.calendar.event, gives the events.
  3. From each member’s PDS, listRecords for their repo, collection community.lexicon.calendar.rsvp, gives their RSVPs.
  4. Group RSVPs by subject.uri, which is the event’s URI.
const space = `at://${group}/space/fyi.opensocial.events/self`;
const records = await readSpace(session, space, [
  "community.lexicon.calendar.event",
  "community.lexicon.calendar.rsvp",
]);
const events = records.filter((r) => r.collection === "community.lexicon.calendar.event");
const going = (eventUri: string) =>
  records.filter(
    (r) =>
      r.collection === "community.lexicon.calendar.rsvp" &&
      r.value.subject.uri === eventUri &&
      r.value.status === "community.lexicon.calendar.rsvp#going",
  );

A reader who isn’t a member gets UserNotAuthorized at the credential step. Show a “members only” state, not an error:

  • getJoinRequest (?group=) tells you whether they’ve already asked. If request is present, show “request pending”.
  • Otherwise offer “Ask to join”, which calls requestJoin with { group, message }. It returns admitted for an open group or pending for one that reviews requests.
  • cancelJoinRequest withdraws a pending request.

3. RSVP

A member writes their RSVP from their own PDS. The rkey is the event’s rkey, so changing their answer overwrites it (A member’s own records). The calendar lexicons declare tid keys; reusing the event’s rkey departs from that on purpose, so one member has at most one RSVP per event. Seeded events may have readable rkeys like cougar-loop, so don’t assume every key is a TID:

await new Client(session).call(com.atproto.space.putRecord, {
  space,
  repo: session.did,
  collection: "community.lexicon.calendar.rsvp",
  rkey: event.rkey,
  record: {
    $type: "community.lexicon.calendar.rsvp",
    subject: { uri: event.uri, cid: event.cid },
    status: "community.lexicon.calendar.rsvp#going",
    createdAt: new Date().toISOString(),
  },
});

4. Post an event as the group

An admin posts the event, and the group authors it. Get a getGroupAuth token for the events space, then write into the group’s repo on the host (Writing as the group):

const grant = await getGroupAuth(session, group, space); // { accessJwt, pds, … }
await createRecordWithDpop(grant, {
  space,
  repo: group,
  collection: "community.lexicon.calendar.event",
  record: {
    $type: "community.lexicon.calendar.event",
    name: "Saturday coffee ride",
    startsAt: "2026-10-03T15:00:00.000Z",
    mode: "community.lexicon.calendar.event#inperson",
    status: "community.lexicon.calendar.event#scheduled",
    createdAt: new Date().toISOString(),
  },
});

getGroupAuth returns Forbidden for someone whose role has no credentialScopes in this space. To know in advance who may post, and show the “new event” button only to them:

  1. Read the viewer’s roles: their fyi.opensocial.membership record in the members space (repo = the group, rkey = the viewer’s DID) has roles.
  2. Read the events space’s access record: fyi.opensocial.access, rkey self, in the group’s repo in that space.
  3. The viewer may post if any entry in credentialScopes names one of their roles and lists community.lexicon.calendar.event, or "*", which means every collection in the space.

5. Signed-out visitors

A signed-out visitor can’t read the events space. Show the group’s name, description and rules from listGroups and a sign-in button.