Calendars
calendarStore events with recurrence and time zones, import and export iCalendar, and show free/busy without showing details.
calendarFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
calendar stores events with recurrence and time zones, imports and exports iCalendar, and shows free/busy without details. Reach for it for bookings, team schedules or a mail client's calendar.
Events keep the wall-clock time a person chose in their zone, so "every Monday at 09:00 in New York" stays at 09:00 through daylight-saving changes. Recurring series can be edited one occurrence at a time or from a date onward.
At a glance
calendar:
store: db
max-occurrences: 5000
max-window: 366dimport { calendar } from "mainsdk";
await calendar.create("team", {
title: "Stand-up", start: "2026-11-02T09:00", end: "2026-11-02T09:15",
timeZone: "America/New_York", rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
});Calls
calendar.createCalendar({ id?, name, owner? })calendar.calendars()calendar.create(cal, event)calendar.get(id)calendar.update(id, patch, { scope, occurrence?, version })calendar.delete(id, { scope, occurrence? })calendar.list({ calendars, from, to, cursor?, limit? })calendar.freeBusy({ calendars?, members?, from, to })calendar.import(cal, source)calendar.export(cal, { from?, to? })calendar.subscribe(cal)
Drivers
3 drivers. Select one to see its line in main.yaml.
calendar:
driver: dbChange this one line to swap providers; your calls stay the same.Your database integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-CAL-0001 | Checkmainsdk check, before deploy | An event is invalid: end before start, an unknown IANA zone, a date-time mixed with a date, or no title (400) |
MAIN-CAL-0002 | Checkmainsdk check, before deploy | A recurrence rule is invalid or uses an unsupported part (400) |
MAIN-CAL-0003 | RuntimeError responses and logs | A window passes max-window, or the expansion passes max-occurrences (400) |
MAIN-CAL-0004 | RuntimeError responses and logs | iCalendar text can't be parsed at a line; that event is skipped |
MAIN-CAL-0005 | RuntimeError responses and logs | An import is larger than calendar.max-import (413) |
MAIN-CAL-0006 | RuntimeError responses and logs | No calendar with that id in the tenant, or access denies it (404 or 403) |
MAIN-CAL-0007 | RuntimeError responses and logs | An edit scope that doesn't fit the event or occurrence (400) |
MAIN-CAL-0008 | RuntimeError responses and logs | The event changed since version was read (409) |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| calendar@1 contract | Phase 3 | 1.2 | calendar#contract |
| Events in IANA time zones | Phase 3 | 1.2 | calendar#events |
| RFC 5545 recurrence | Phase 3 | 1.2 | calendar#rrule |
| Edit one, following or all occurrences | Phase 3 | 1.2 | calendar#edit-scope |
| iCalendar import and export | Phase 3 | 1.2 | calendar#ical |
| Free/busy | Phase 3 | 1.2 | calendar#freebusy |
Quick start
calendar:
store: db
max-occurrences: 5000
max-window: 366dimport { Calendar } from "mainsdk/experimental/calendar";
type CalendarDeps = ConstructorParameters<typeof Calendar>[0];
declare const config: Record<string, unknown>; // the calendar section of main.yaml
declare const stores: CalendarDeps["stores"]; // the region SQL stores your db uses
declare const tenant: CalendarDeps["tenant"];
declare const currentUser: { id: string };
const calendar = new Calendar({
config,
stores,
tenant,
subject: () => ({ id: currentUser.id }),
});
await calendar.createCalendar({ id: "team", name: "Team" });
await calendar.create("team", {
title: "Stand-up", start: "2026-11-02T09:00", end: "2026-11-02T09:15",
timeZone: "America/New_York", rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
});Configuration
| Key | Default | Meaning |
|---|---|---|
store | db | Where events live: db or local. CalDAV, Google and Microsoft come later through connections |
max-occurrences | 5000 | The most occurrences one list call expands |
max-window | 366d | The widest window a list or free/busy call may ask for |
max-import | 5MB | The largest iCalendar text an import accepts |
What you get
Every call runs in the current tenant and checks access on the calendar.
| Call | What it does |
|---|---|
calendar.createCalendar({ id?, name, owner? }) | Make a calendar; the owner is the caller unless one is named |
calendar.calendars() | List the calendars the caller may read |
calendar.create(cal, event) | Add an event. start and end are local date-times, or dates for an all-day event; timeZone is an IANA name, and without one the event floats. Takes rrule, exdates and rdates for recurrence |
calendar.get(id) | Read one event with its version |
calendar.update(id, patch, { scope, occurrence?, version }) | Change an event. scope is this, following or all; a stale version fails with MAIN-CAL-0008 |
calendar.delete(id, { scope, occurrence? }) | Delete one occurrence, this and the following, or the whole series |
calendar.list({ calendars, from, to, cursor?, limit? }) | Return expanded occurrences in a window, as UTC instants plus local times |
calendar.freeBusy({ calendars?, members?, from, to }) | Return merged busy intervals, with tentative ones marked. It never returns titles, places or attendees to someone who cannot read the calendar |
calendar.import(cal, source) | Read iCalendar text or a file. Events with the same UID update instead of duplicating; a bad event is skipped and listed with its line |
calendar.export(cal, { from?, to? }) | Write valid RFC 5545 text that imports back unchanged |
calendar.subscribe(cal) | Return a getqr.id share link to the calendar feed, never a raw URL |
Always:
- Wall-clock times stay put across daylight-saving changes. A time in the spring-forward gap moves forward by the gap, and a time that happens twice uses the first.
- Recurrence expansion is bounded by
max-windowandmax-occurrences. - Free/busy never shows titles, places or attendees to someone who can't read the calendar.
- Events stay in the tenant's region and are labelled
personal.
Examples
Move one Monday
import type { Calendar } from "mainsdk/experimental/calendar";
declare const calendar: Calendar;
declare const eventId: string;
const week = await calendar.list({ calendars: ["team"], from: "2026-11-01", to: "2026-11-30" });
await calendar.update(eventId, { start: "2026-11-17T11:00", end: "2026-11-17T11:15" }, {
scope: "this", occurrence: week.items[2].occurrence, version: 1,
});Show when people are busy
import type { Calendar } from "mainsdk/experimental/calendar";
declare const calendar: Calendar;
const { busy } = await calendar.freeBusy({ calendars: ["a", "b"], from: "2026-11-02T00:00", to: "2026-11-03T00:00" });Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-CAL-0001 | An event is invalid: end before start, an unknown zone, mixed date and date-time, or no title (400) | Use an IANA zone and a start before the end |
MAIN-CAL-0002 | A recurrence rule is invalid or uses an unsupported part (400) | Drop the named part, or use a supported one |
MAIN-CAL-0003 | A window passes max-window, or expansion passes max-occurrences (400) | Ask for a narrower window |
MAIN-CAL-0004 | iCalendar text can't be parsed at a line; that event is skipped | Fix the line the result names |
MAIN-CAL-0005 | An import is larger than max-import (413) | Split the file or raise max-import |
MAIN-CAL-0006 | No such calendar in the tenant, or access denies it (404 or 403) | Use a calendar you may read |
MAIN-CAL-0007 | An edit scope that doesn't fit the event (400) | Use all on a single event |
MAIN-CAL-0008 | The event changed since version was read (409) | Read the event again and retry |
mainsdk check has a calendar gate: MAIN-CAL-0001 and MAIN-CAL-0002 in seed data and main.yaml, each with its file and line. Each code's cause and fix is in check codes.
Swapping drivers
store: db keeps events in the tenant's region store; store: local keeps them in process for tests and mainsdk dev. The calls do not change. A CalDAV driver comes later, with a connection.
Testing
store: local runs offline on the fake clock, so recurrence and daylight-saving cases need no real time. See Test your app.
Security and privacy
- Every call checks
accesson the calendar, and another tenant's calendar reads as missing. privacy.exportincludes the calendars a person owns and the events they created or attend;privacy.erasedeletes owned calendars and removes the person from other events' attendees.- Subscriptions are getqr.id links, so the feed address is never handed out raw.
Next
- Use calendars in your app: the steps, end to end.
- Inbound mail
- Access
Was this page helpful?