Calendar scheduling — booking surface + calendar sync, shipped together
SkillProductivityUse when a product needs a booking surface — a pick-a-slot page, a Cal.com/Calendly embed, or real availability plus the confirmed meeting written to Google/Outlook — or when fixing double-booking, DST drift, or orphaned reschedule events. NOT calendar CRUD with no booking surface (that is `google-workspace`), NOT the payment (that is `stripe`).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Calendar scheduling — booking surface + calendar sync, shipped together skill
What this skill tells your AI
The instructions your AI receives, as published by ericrisco/rsc-harness in skills/calendar-scheduling/SKILL.md and read by ahel’s review.
Scheduling is always two halves bolted together: a booking surface (an external person reserves a slot — embed, atom, or API call) and calendar sync (you read free/busy to compute availability and write the confirmed event back). Ship one without the other and you get the three bugs the rest of this skill exists to prevent: double-booking, timezone drift after a DST change, and orphaned events on reschedule.
Decide the altitude first
Pick the lowest-code option that still owns the data model you actually need.
| You need… | Reach for | What you own | Escape hatch |
|---|---|---|---|
| A booking page fast, minimal code | Embed Cal.com or Calendly | Nothing — the widget owns slots/sync | Call the API later to read bookings / fire automation |
| Bookings in your UI, your branding/data model | Cal.com Booker atom or Scheduling API (Cal.com / Calendly) | Your UI; provider owns sync | Drop to raw provider API if the data model chafes |
| Read/write one provider's calendar directly | Google freebusy.query + events.insert | OAuth, refresh, slot math, watch/sync | If it's pure CRUD with no booking → google-workspace |
| Many providers (Google + Outlook + Apple), no N integrations | Unified API (Cronofy or Nylas v3) | One auth/availability surface | Cronofy if cross-domain scheduling matters; Nylas v3 is domain-scoped |
Why per row: the embed is zero-maintenance but a black box; the atom/API buys
your own UI without owning sync; raw provider is full control and full
liability; a unified API trades a vendor for not maintaining three calendar
integrations. Hosting, auth/scopes, webhook events, cross-domain support and
when each wins, per provider:
references/provider-matrix.md.
- Cal.com is open-source and self-hostable. Self-hosted instances get
unlimited API access (no cloud rate limit) and full white-label by
pointing the embed script at your own domain. REST base is
https://api.cal.com/v2. - Calendly v1 API and its webhooks were discontinued in May 2025. Use v2 (REST/JSON, OAuth 2.1 or personal access token). Do not write new v1 code.
OAuth scopes — narrowest that works
The default mistake is requesting the broad scope "to be safe." On Google, both
calendar and calendar.events are restricted scopes — they force a
third-party security assessment before you can ship to production. Avoid
them when a granular scope does the job.
// Bad — restricted scope, blocks production until a security assessment.
const SCOPES = ["https://www.googleapis.com/auth/calendar"];
// Good — granular ladder, no restricted tier for the common booking case.
const SCOPES = [
"https://www.googleapis.com/auth/calendar.app.created", // app-owned secondary calendar it creates
"https://www.googleapis.com/auth/calendar.freebusy", // your own availability
// add only if you must read the user's existing events to compute slots:
"https://www.googleapis.com/auth/calendar.events.owned", // manage only events your app created
"https://www.googleapis.com/auth/calendar.events.freebusy", // others' busy blocks
];
Scope ladder, narrowest first:
calendar.app.created— a dedicated secondary calendar your app creates and owns. Best dodge for the restricted assessment when you only need your events.calendar.freebusy/calendar.events.freebusy— read availability (own / others') without reading event contents.calendar.readonly/calendar.events.readonly— read paths only.calendar.events.owned— write, but only events your app created.calendar/calendar.events— restricted; request only if you genuinely manage arbitrary events the app didn't create.
Availability without double-booking — the core flow
Both classic races (computing slots in the browser, and writing the event before re-checking) are eliminated by doing this server-side, in order:
freebusy.queryacross every relevant calendar (the host's, plus any secondary calendars that block time). Never trust a cached availability blob.- Compute slots server-side applying buffers (gap before/after), minimum notice (no "book in 5 minutes"), working hours, and slot length. The browser may render slots; it must never decide them.
- Place a short-lived hold/lock on the chosen slot (a row with a TTL, or a tentative event) so a second request in the same window collides on the lock, not on the calendar.
- Write the event LAST — only after the lock is held.
- Re-check
freebusyinside the write transaction. If the slot went busy between step 1 and now, abort and re-offer. This is the line that actually prevents the double-book.
Decision — do you need a hold step?
| Situation | Hold/lock? |
|---|---|
| Low traffic, single host, instant write | No — steps 1→5 with the in-transaction re-check is enough |
| Multi-step booking form, payment, or high contention | Yes — a TTL lock so the slot survives the form and releases if abandoned |
Google's availability primitive is freebusy.query (POST, returns busy blocks
per calendar); the write is events.insert. Both payloads (with
conferenceData for Meet), watch channels + sync tokens, recurring-event edge
cases and refresh-token handling:
references/google-calendar-sync.md.
Timezone correctness
DST is where naive scheduling code dies. Rules:
- Store the instant in UTC and carry the IANA zone id (e.g.
Europe/Andorra) separately. Never store a bare wall-clock string. - Render in the invitee's zone, derived from the IANA id — not from a
browser UTC offset. An offset (
+02:00) is correct only on the day it was captured; it silently breaks across a DST boundary. - Google event payloads MUST set
timeZonealongsidedateTime, or Google interprets the time in the calendar's default zone and the meeting drifts.
// Bad — floating wall-clock, no zone. Drifts after the clocks change.
{ "start": { "dateTime": "2026-10-25T10:00:00" } }
// Good — instant + explicit IANA zone on both ends.
{
"start": { "dateTime": "2026-10-25T10:00:00", "timeZone": "Europe/Andorra" },
"end": { "dateTime": "2026-10-25T10:30:00", "timeZone": "Europe/Andorra" }
}
Webhooks that survive retries
A booking is not confirmed because the embed said so — it is confirmed when the webhook says so. Providers retry, deliver duplicates, and arrive out of order. Your handler must assume all three.
- Verify the signature before trusting the payload (Cal.com and Calendly each sign; reject unsigned).
- Dedupe on the provider event id — an idempotency key persisted before you act, so a retry is a no-op.
- Handle the lifecycle: Calendly fires
invitee.created/invitee.canceled(and routing-form submissions); Cal.com firesBOOKING_CREATED/BOOKING_CANCELLED/BOOKING_RESCHEDULED. Map both to your own created/canceled/rescheduled handlers. - Calendly webhooks require a paid plan (Standard/Teams/Enterprise) and are
scoped
userororganization. Single-use scheduling links expire after 90 days if unused — don't hand out links you cache forever.
The generic inbound-receiver scaffolding (queue, retry, replay) lives in the webhooks skill; this skill only owns the booking-specific lifecycle mapping. For "on booking, also create a CRM record + Slack + sheet" cross-tool fan-out, that orchestration is automation-flows, not here.
Reschedule and cancel without orphans
- Reschedule = update the same calendar event id. Look up the event you
created,
events.update(or the provider's PATCH) the times — neverevents.inserta second one. The phantom-event bug is always a missing lookup. - Release the freed slot — if you held a lock or marked a row busy, free it so the old time is offerable again.
- Cancel = delete/cancel the same event and release the slot; record the cancellation so reminders and downstream automation stop.
Anti-patterns
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
| Compute available slots in the browser | Stale/raced data → double-book | freebusy.query server-side, re-check in the write txn |
Request auth/calendar for a read-only widget | Restricted scope → blocked by security assessment | Narrowest scope: calendar.freebusy / calendar.app.created |
| Store local "wall-clock" times | Drift after DST → wrong-hour meetings | UTC instant + IANA zone; set timeZone on Google payloads |
| Trust the embed for confirmation state | Embed lies on network failures | Confirm only on a signature-verified webhook |
| Create a new event on reschedule | Orphaned phantom events pile up | events.update the same event id; release old slot |
| No idempotency on the webhook | Retries duplicate the booking | Dedupe on provider event id before acting |
| Cache a single-use scheduling link forever | Calendly links expire after 90 days | Generate on demand; treat expiry as expected |
| Poll the calendar for changes | Slow, rate-limited, misses edits | watch push channels + incremental sync tokens |
| Write new code against Calendly v1 | v1 API + webhooks dead since May 2025 | Calendly v2 (OAuth 2.1 / PAT) |
Adjacent skills: raw calendar CRUD / watch channels with no booking →
../google-workspace/SKILL.md; charging for a
paid appointment → ../stripe/SKILL.md; booking funnel as
sales stages → ../sales-pipeline/SKILL.md;
sending the confirmation email itself → ../email-connector/SKILL.md.
Signals
- GitHub stars
- 82
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
calendar-scheduling- Source
- github.com/ericrisco/rsc-harness