[Proposal] Scheduling in the calendar namespace: calendar.availability and calendar.booking

Hey y’all! I’m Bryan (@chaosgreml.in), and I build Tandem, a 1:1 booking page on atproto. Tandem is live now. It follows the standard process all these apps have: a host signs in with their atproto account, sets weekly hours, and connects whatever calendars hold their busy time: ICS, CalDAV, Google, Outlook. Then they share /@handle, and guests book the open slots. Every record lands under space.intandem.*, written into the host’s own permissioned space, for any PDS that supports permissioned spaces.

I’m making this proposal because scheduling and availability inform so much of what we do with calendars , and right now everyone is doing a bunch of different lexicons, when interop here would allow us to use availability and scheduling across apps and for different purposes, like comparing your schedule to events on atmo.rsvp, or doing group booking in OpenMeet or Convene.

Below is the smallest thing I think we can all agree on: one record for when you can be booked, and one for what got booked. Everything else stays with each app.

Disclosure: I used an agent to help me write the spec, but I reviewed it fully and tried to edit out a bunch of the extra verbal slop. Any mistakes and opinions on the structure are mine.

The need

Scheduling apps keep reinventing the same two records. Searching the network turns up availability and booking schemas from at least five apps, none published or shared:

NSID App
space.intandem.availability, space.intandem.booking Tandem
boo.sky.convene.availability, boo.sky.convene.booking Convene
chat.avails.scheduling.availability avails.chat
lol.letsmeet.availability letsmeet
app.whenslunch.calendar.availability whenslunch

Two shared records would let:

  • a host keep one set of hours and move between booking apps, or use several, without
    re-entering them;
  • a booking show up as an ordinary event in every app that already reads community.lexicon.calendar.event (atmo.rsvp, Smoke Signal), with no booking-specific code;
  • a guest with an atproto account confirm the booking from their own repo with the RSVP
    they already know how to write.

Smaller than a scheduling system

This proposes only the two records every booking app has. Slots are not records: an app computes them from the availability record minus the host’s busy time, the way a calendar app expands recurrence. That follows the point made in the Recurring Events thread that virtual events break strong references, so the only concrete record a booking produces is a real event.

Prior art

  • RFC 7953 (VAVAILABILITY) and VFREEBUSY in iCalendar. VAVAILABILITY expresses windows as recurring AVAILABLE components with RRULEs. We chose a flat weekly list because every app above models it that way and it validates without an RRULE engine. RRULE windows can arrive later as an open union member if two implementers need them.
  • Cal.com and Calendly event types: duration, buffer, minimum notice, booking horizon,
    weekly hours. Those four rules are the common core; their other options are not.
  • OpenMeet series and the Recurring Events thread (#590): a rule record that produces
    concrete events. Availability is the same shape: rules here, real events produced on booking.
  • Declaring speakers on calendar events (#1121): sidecars and two-sided attestation rather than growing the event record. booking is a sidecar for that reason.

How it fits calendar.event and calendar.rsvp

We do not add a single field to event or rsvp.

host's repo                                   guest's repo (optional)
──────────                                    ───────────────────────
calendar.availability/30min   ◄─at-uri──┐
calendar.event/3m5ab…         ◄─────────┼──strongRef── calendar.rsvp/… (#going)
calendar.booking/3m5ab…  (same rkey) ───┘

When a guest books, the host’s app writes two records with one record key:

  1. a community.lexicon.calendar.event with the time, a name, mode, a
    #scheduled status, and the meeting link in uris. Cancelling sets #cancelled,
    moving sets #rescheduled.
  2. a community.lexicon.calendar.booking sidecar with who the
    guest is, their note, and which availability record they booked against.

Sharing the key makes “a booking without its event” unrepresentable and lets a reader go from either record to the other without an index. I chose a sidecar over an embedded additionalData.booking on the event (the way atmo.rsvp attaches parentEvent and speakers) because the guest is personal data: it can live in a different place under
different rules than the event, and readers that only care about events never load it. A signed-in guest can complete the attestation pair with an RSVP to the event; the host’s sidecar names the guest, the
guest’s RSVP confirms it.

community.lexicon.calendar.availability

Field Req Why
timezone ✓ IANA zone. Windows are wall-clock, so they follow daylight saving. Same name and format as the timezone atmo.rsvp already writes on events.
durationMins ✓ Length of one booking. Integer minutes, no floats.
windows ✓ Weekly windows; empty means not bookable for now.
createdAt ✓ Convention across the namespace.
name “30-minute intro call”.
description Shown to guests.
bufferMins Gap kept free around bookings. Default 0.
minNoticeMins How far ahead a booking must be made. Default 0.
horizonDays How far out bookings are offered; absent means app default.

window: weekday (ISO 8601, 1 = Monday), start and end as HH:MM, end exclusive,
24:00 for end of day, no window crosses midnight.

Record key any. One account can offer several kinds of booking, and the key often carries meaning: it can be the slug in a booking URL (/@alice/30min). An app with one kind can use self.

{
  "lexicon": 1,
  "id": "community.lexicon.calendar.availability",
  "defs": {
    "main": {
      "type": "record",
      "description": "When the author can be booked: weekly windows in one time zone, and the rules every booking must meet. Open slots are computed from this record, minus the author's busy time, by the app that takes the booking; slots are never stored as records.",
      "key": "any",
      "record": {
        "type": "object",
        "required": ["timezone", "durationMins", "windows", "createdAt"],
        "properties": {
          "name": { "type": "string", "maxLength": 640, "maxGraphemes": 64, "description": "Short label for this kind of booking, e.g. \"30-minute intro call\"." },
          "description": { "type": "string", "maxLength": 3000, "maxGraphemes": 300, "description": "What a guest is booking, shown on the booking page." },
          "timezone": { "type": "string", "maxLength": 64, "description": "IANA time zone name, e.g. America/Los_Angeles. Windows are wall-clock times in this zone, so they follow daylight saving changes." },
          "durationMins": { "type": "integer", "minimum": 5, "maximum": 1440, "description": "Length of one booking, in minutes." },
          "bufferMins": { "type": "integer", "minimum": 0, "maximum": 1440, "default": 0, "description": "Time kept free between a booking and any other busy time, in minutes." },
          "minNoticeMins": { "type": "integer", "minimum": 0, "default": 0, "description": "How long before its start a booking must be made, in minutes." },
          "horizonDays": { "type": "integer", "minimum": 1, "maximum": 366, "description": "How many days ahead bookings are offered. Absent means the booking app's own default." },
          "windows": { "type": "array", "maxLength": 100, "items": { "type": "ref", "ref": "#window" }, "description": "Weekly windows inside which a booking must start and end. An empty array means not bookable for now." },
          "createdAt": { "type": "string", "format": "datetime", "description": "Client-declared timestamp when the record was created." }
        }
      }
    },
    "window": {
      "type": "object",
      "description": "One weekly window. A window does not cross midnight; split overnight availability into two windows.",
      "required": ["weekday", "start", "end"],
      "properties": {
        "weekday": { "type": "integer", "minimum": 1, "maximum": 7, "description": "ISO 8601 weekday: 1 = Monday, 7 = Sunday." },
        "start": { "type": "string", "maxLength": 5, "description": "Wall-clock start, HH:MM on a 24-hour clock, in the record's timezone." },
        "end": { "type": "string", "maxLength": 5, "description": "Wall-clock end, HH:MM on a 24-hour clock, exclusive and later than start. 24:00 means the end of the day." }
      }
    }
  }
}

community.lexicon.calendar.booking

Field Req Why
guest ✓ Who booked: did, name, or both; email and timezone optional.
createdAt ✓ When the booking was made.
availability The availability record booked against. An at-uri, not a strongRef: hosts edit hours often and bookings outlive the edits.
note What the guest wrote.

Time, name, status, mode and meeting link are deliberately absent: they live on the event.

{
  "lexicon": 1,
  "id": "community.lexicon.calendar.booking",
  "defs": {
    "main": {
      "type": "record",
      "description": "Booking details for a community.lexicon.calendar.event that a guest booked against an availability record. A sidecar: written by the host, in the same repository and with the same record key as the event. The event holds the time, name, mode, status and links; this record holds only what booking adds. Guest fields are personal data, see #guest.",
      "key": "tid",
      "record": {
        "type": "object",
        "required": ["guest", "createdAt"],
        "properties": {
          "availability": { "type": "string", "format": "at-uri", "description": "The community.lexicon.calendar.availability record the booking was made against. An at-uri rather than a strongRef: hosts edit availability often, and a booking outlives those edits." },
          "guest": { "type": "ref", "ref": "#guest" },
          "note": { "type": "string", "maxLength": 30000, "maxGraphemes": 3000, "description": "What the guest wrote when booking." },
          "createdAt": { "type": "string", "format": "datetime", "description": "Client-declared timestamp when the booking was made." }
        }
      }
    },
    "guest": {
      "type": "object",
      "description": "Who booked. Set did, name, or both. These fields identify a person: an app should write them only where the host and guest can expect them to stay private, and must not write email to a public repository.",
      "properties": {
        "did": { "type": "string", "format": "did", "description": "The guest's account, when they booked signed in. The guest may confirm with a community.lexicon.calendar.rsvp whose subject is the event." },
        "name": { "type": "string", "maxLength": 3000, "maxGraphemes": 300, "description": "The name the guest gave." },
        "email": { "type": "string", "maxLength": 500, "description": "The address the guest gave for confirmations." },
        "timezone": { "type": "string", "maxLength": 64, "description": "IANA time zone the guest booked in, so the host can show times in the guest's zone." }
      }
    }
  }
}

Example records

A host, did:plc:host…, offers a 30-minute call on weekday afternoons:

{
  "$type": "community.lexicon.calendar.availability",
  "name": "30-minute intro call",
  "timezone": "America/Chicago",
  "durationMins": 30,
  "bufferMins": 10,
  "minNoticeMins": 720,
  "horizonDays": 21,
  "windows": [
    { "weekday": 1, "start": "13:00", "end": "17:00" },
    { "weekday": 2, "start": "13:00", "end": "17:00" },
    { "weekday": 4, "start": "09:00", "end": "12:00" }
  ],
  "createdAt": "2026-11-02T15:04:00.000Z"
}

Ada books Tuesday at 14:30 Chicago time. The host’s app writes the event and its sidecar
under rkey 3m5abcxyz2k2a:

{
  "$type": "community.lexicon.calendar.event",
  "name": "Intro call with Ada",
  "startsAt": "2026-11-10T20:30:00.000Z",
  "endsAt": "2026-11-10T21:00:00.000Z",
  "mode": "community.lexicon.calendar.event#virtual",
  "status": "community.lexicon.calendar.event#scheduled",
  "uris": [{ "uri": "https://meet.example.com/ada-intro", "name": "Video call" }],
  "createdAt": "2026-11-05T18:12:44.000Z"
}
{
  "$type": "community.lexicon.calendar.booking",
  "availability": "at://did:plc:host…/community.lexicon.calendar.availability/30min",
  "guest": { "did": "did:plc:ada…", "name": "Ada", "timezone": "Europe/London" },
  "note": "Would love to talk about the scheduling lexicons.",
  "createdAt": "2026-11-05T18:12:44.000Z"
}

Ada, signed in, confirms from her own repo:

{
  "$type": "community.lexicon.calendar.rsvp",
  "subject": { "uri": "at://did:plc:host…/community.lexicon.calendar.event/3m5abcxyz2k2a", "cid": "bafyrei…" },
  "status": "community.lexicon.calendar.rsvp#going"
}

Absent by design

A field gets in when two independent implementers need it. Left out for now:

  • Which calendars count as busy. Connections to ICS, CalDAV, Google or Outlook carry
    credentials and are private to each app.
  • Published free/busy. A VFREEBUSY-style record would publish a person’s schedule.
    Slots stay computed on demand.
  • Date overrides (holidays, one-off extra hours), several durations per record, group bookings, round robin, payments, reminders, intake questions.
  • Location and mode on availability. The event carries them; an app copies its own defaults onto it.

Extension points

  • New keys are free: any lets apps add kinds of booking without schema changes.
  • App-specific booking data goes in another same-rkey sidecar, or a namespaced embedded field with its $type, per the sidecar patterns thread (#1353).
  • RRULE windows or date overrides, if they earn a place, arrive as new optional fields; nothing here needs changing.

Privacy

Availability is meant to be public, like any booking page. Bookings are not: an event plus a sidecar says who is meeting whom, and when. The schemas don’t depend on permissioned spaces, but they are written with them in mind:

  • guest says in its description that it identifies a person, and that email must never
    go to a public repository.
  • Tandem writes every booking record to the host’s permissioned space on the spaces alpha,
    never to the public repo.
  • A public-repo app should write a booking only when host and guest have both agreed to
    it. The guest’s RSVP is the guest’s own choice to make public.

Permissions

community.lexicon.calendar.authFull lists the calendar collections. I propose adding
availability and booking to its repo permission, and am open to a narrower authScheduling set instead (see question 5).

Implementations

  • Tandem (https://intandem.space, source at https://tangled.org/chaosgreml.in/tandem):
    live, writing space.intandem.availability and space.intandem.booking today. Once the
    schemas settle, Tandem writes the community records alongside its own, then switches
    over: hours map directly (minNoticeHours × 60, weekday 0 → 7), and each booking becomes an event plus sidecar.
  • atmo.rsvp ( GitHub - flo-bit/atmo-events · GitHub ) as a reader: it renders calendar.event, so a public booking shows there unchanged. I’d value @flo-bit.dev’s
    review, since atmo.rsvp is where most event fields get tested in the wild.
  • Looking for a second implementer. If you build Convene, avails.chat, letsmeet, whenslunch, or any app that shows events, I’d love you in the working group.

Open questions

  1. Weekly list or RRULE? Is a flat weekly list enough to start, with RRULE (RFC 7953)
    windows as a later optional field?
  2. Sidecar, embedded, or self-contained? Two alternatives to the sidecar:
    • embedded: the event carries additionalData.booking, as atmo.rsvp does for conference talks. One record and one write, but the guest’s details travel wherever the event goes, and every event reader loads them.
    • self-contained: one booking record carrying startsAt, endsAt and an event
      status. One write, but event readers wouldn’t see bookings, and it repeats the event’s
      fields.
  3. Should availability be required on a booking? It’s optional now, so hand-made
    bookings and migrated history validate.
  4. Weekday encoding: ISO 1–7 integers, or knownValues tokens (#monday…) like the event’s mode and status?
  5. authFull or a new permission set?
  6. Date overrides: does anyone besides us need them in the base record?

The ask

Feedback on the shape, especially questions 1, 2 and 6, and a working group. We discussed possibly a common events/calendar working group at the lexicon.community meeting this week, but if this is meaningfully different enough, I’m happy to lead a working group as below:

Working group: Scheduling

  • Objective: shared records for when someone can be booked and what was booked.
  • Use case: Tandem today; any booking or calendar app.
  • Expected deliverable: a PR adding community/lexicon/calendar/availability.json and booking.json, plus the authFull update, with these examples and Tandem as reference code.
  • Timeline: 3-4 weeks of discussion, then the PR.
  • Lead: @chaosgreml.in
  • Members: looking for at least one more, ideally another booking app.
  • TSC representative: looking for a sponsor.
4 Likes

I was planning to eventually add that into atmo.rsvp too as a tool, some thoughts:

  • i wouldnt necessarily expect my availability to be public (in the sent-over-firehose sense), id probably prefer a dedicated space (that i can still set to public and share the link, but cant just be found by listening to the firehose), makes it potentially easier to also add blocked out times etc in there with less privacy concerns
  • not a big fan of the sidecar thing in general and imo not really needed here, i think we should probably implement something like @gui.do proposal in a generic way (array of people with roles on event records and use role=“guest” here or similar). thats the only required record (apart from createdAt which is already in event) on booking and note can just be in event.description and availability record booked against can be in event.uri (and yeah that means all apps that have access to your event spaces can see that, but that would probably be the case either way as the booking record would live in those spaces too [it has to afaik as cross-space linking is not supported?])

for q #5 that strongly suggests a new permission set to me

(not super strong) opinions regarding the other questions

  1. weekly
  2. not required
  3. knownValues

tbh still not 100% sure how that whole space stuff is gonna work for events and availability, gotta think about that a bit more

3 Likes

Spaces! I’ve been thinking about using spaces as a backend for https://tangled.org/ngerakines.me/eye-of-agamotto. This’d be an interesting feature / extension.

4 Likes

yeah a calendar with my private events living in a personal space is something i want too and would be cool if that also automatically restricts my availability (cause it knows i have a different event scheduled already but doesnt show what that is)

3 Likes