Google Calendar
Calendar has two paths, like Asana and Fathom: a read mirror (calendar-sync) that keeps two
different caches current, and a gated write executor (calendar-act) that only creates or changes
Google Calendar events after a human approves the specific action.
How it works
Mirror (calendar-sync)
calendar-sync authenticates to Google as a service account, using a JWT-bearer exchange
(RS256, signed with crypto.subtle) for an OAuth token scoped calendar.readonly. It reuses the
same service-account JSON as Drive (GOOGLE_DRIVE_JSON from the Vault, or GDRIVE_SA_JSON /
GOOGLE_DRIVE_JSON env), just requesting a different scope. Reading another person's free/busy
data requires domain-wide delegation impersonation via GOOGLE_CALENDAR_SUBJECT.
The function supports three independent request shapes:
{ dryRun: true }— authenticate and list calendars plus a small event sample; writes nothing.{ freeBusy: true }(orrefreshAvailability: true) — refreshcalendar_busyfrom Google free/busy. This produces opaque busy/free blocks only — no titles, no attendees.{ events: true }— full event-detail ingest intocalendar_events, including titles, attendees, and project matching.
Free/busy refresh. For each person with a readable calendar, the sync pulls their busy blocks
and writes calendar_availability_status (one row per person/source) plus calendar_busy rows.
Per-person read errors are collected as warnings rather than aborting the run — the only fatal
condition is zero readable calendars across the whole org. The prune step that removes stale busy
rows only deletes rows for people who successfully refreshed this run; it deliberately never
prunes org-wide, because an earlier version let one person's calendar error wipe out everyone's
cached availability.
Event-detail ingest. For each readable calendar, the sync pages through events (following
nextPageToken to completion), and for each event determines project linkage using project
codes/names, client names, explicit project signals, external contacts, and attendee emails/domains
— in that preference order. If matching is ambiguous or wrong, an admin/lead can set a durable
manual override from the Calendar page (see below); overrides live in their own table so the next
hourly sync doesn't wipe out the correction. Events are upserted into calendar_events on
(org_id, calendar_id, external_id). Stale events are marked cancelled only for calendars that
were read successfully and not truncated by pagination — a truncated page is never treated as proof
that unseen events were cancelled.
Act executor (calendar-act)
calendar-act executes one approved calendar_event_create, calendar_event_update, or
calendar_event_cancel approval per call, using the calendar.events scope and impersonating a
per-approval Workspace subject (or GOOGLE_CALENDAR_SUBJECT as a fallback). It requires the same
admin/lead-or-cron-with-actorPersonId auth pattern as the other act executors, plus
CALENDAR_ACT_WRITES_ENABLED=true. On success it upserts/updates calendar_events and writes an
audit row.
Configuration
| Setting | Value |
|---|---|
| Secret | Google service-account JSON (shared with Drive): GOOGLE_DRIVE_JSON / GDRIVE_SA_JSON |
| Impersonation | GOOGLE_CALENDAR_SUBJECT (domain-wide delegation subject for free/busy reads) |
| Write gate | CALENDAR_ACT_WRITES_ENABLED=true (edge secret; unset by default) |
| Workspace setup | Domain-wide delegation on the existing service account, calendar.readonly for sync + calendar.events for approved writes; per-calendar sharing works for reads only and doesn't scale for cross-team writes |
| API enablement | Calendar API must be enabled on the GCP project (sable-500515) |
Cron cadence
| Task | Schedule | Calls |
|---|---|---|
sable.calendar.freebusy | Hourly, :40 (40 * * * *) | calendar-sync with {freeBusy:true, events:true, days:14} |
A legacy pg_cron job (sable-calendar-sync) also exists from before Trigger.dev took over
scheduling; only one scheduler should be relied on per connector to avoid overlapping refreshes.
calendar-act has no schedule — it only runs against a specific human approval.
Table mapping
| Table | Written by | Contents |
|---|---|---|
calendar_availability_status | calendar-sync | One row per (org_id, email, source): readable/error/unknown status, window bounds |
calendar_busy | calendar-sync | Busy/free blocks only — no titles, no attendees, ever |
calendar_events | calendar-sync, calendar-act | Full event detail: summary, attendees, project match, conference info; unique on (org_id, calendar_id, external_id) |
calendar_event_project_overrides | Calendar page (via calendar.js) | Durable manual project link/unlink per event, checked before automatic matching |
activity_events | calendar-sync | Ingest activity trail |
connector_checkpoints | calendar-sync | Health bookkeeping |
audit | calendar-act | One row per executed write |
What can go wrong
- The Availability timeline looks fine but the Calendar intelligence panel is empty (or vice
versa). They read from different caches: Availability reads
calendar_busy+calendar_availability_status(opaque blocks), while the intelligence panel readscalendar_events(full detail). A failure in one request shape (freeBusyvsevents) doesn't affect the other. - One person's calendar errors used to blank out everyone's availability. This was fixed — the
prune step only removes rows for people who refreshed successfully this run. If you see a gap now,
it's a genuine per-person read failure, not a prune bug; check
calendar_availability_status.errorfor that person. - An event is linked to the wrong project, or should stay unmatched. Set an explicit override
from the Calendar page —
set_project_overrideorclear_project_overrideviacalendar.js— rather than fighting the automatic matcher. Overrides are checked first on every future sync. - Writes never reach Google Calendar. Check
CALENDAR_ACT_WRITES_ENABLED(unset by default), then confirm the approval'skind/status, and that the domain-wide delegation subject used for the write actually hascalendar.eventsscope for that calendar. - Free/busy reads fail entirely. Confirm the Calendar API is enabled on the GCP project and that the service account has either domain-wide delegation or an explicit per-calendar share with "See all event details."
Where the code lives
supabase/functions/calendar-sync/index.ts— free/busy and event-detail mirrorsupabase/functions/calendar-act/index.ts— approved write executorweb/api/availability.js— team free/busy API (readscalendar_busy+calendar_availability_status)web/api/calendar.js— calendar intelligence API (reads/writescalendar_events, project overrides, event proposals)trigger/connectors.ts—sable.calendar.freebusyschedulesupabase/migrations/0036_calendar_availability.sql,0040_calendar_availability_status.sql,20260710135637_calendar_intelligence.sql,20260710144647_calendar_event_project_overrides.sql