Use 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`).
npx skills add https://github.com/ericrisco/rsc-harness --skill calendar-scheduling
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.
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.
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.
(REST/JSON, OAuth 2.1 or personal access token). Do not write new v1 code.
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 andowns. 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 genuinelymanage arbitrary events the app didn't create.
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.query across every relevant calendar (the host's, plus anysecondary calendars that block time). Never trust a cached availability blob.
minimum notice (no "book in 5 minutes"), working hours, and slot length.
The browser may *render* slots; it must never *decide* them.
tentative event) so a second request in the same window collides on the lock,
not on the calendar.
freebusy inside the write transaction. If the slot went busybetween 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.
DST is where naive scheduling code dies. Rules:
Europe/Andorra) separately. Never store a bare wall-clock string.
browser UTC *offset*. An offset (+02:00) is correct only on the day it was
captured; it silently breaks across a DST boundary.
timeZone alongside dateTime, or Googleinterprets 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" }
}
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.
each sign; reject unsigned).
act, so a retry is a no-op.
invitee.created /invitee.canceled (and routing-form submissions); Cal.com fires
BOOKING_CREATED / BOOKING_CANCELLED / BOOKING_RESCHEDULED. Map both to
your own created/canceled/rescheduled handlers.
scoped user or organization. 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.
created, events.update (or the provider's PATCH) the times — never
events.insert a second one. The phantom-event bug is always a missing lookup.
so the old time is offerable again.
cancellation so reminders and downstream automation stop.
| 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.
Take ericrisco/calendar-scheduling from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.