calendar-scheduling
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 surfac
Install
npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/calendar-scheduling
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
git clone https://github.com/ericrisco/rsc-harness.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ericrisco/rsc-harness collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Calendar scheduling — booking surface + calendar sync, shipped together
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.
Files (rsc-harness)
-
evals
-
cases.yaml 3.3 KB
skill: calendar-scheduling should_trigger: - prompt: "Add a 'book a call' page to our site that shows my real availability and creates the Google Calendar event automatically." why: Core build — booking surface plus calendar sync (freebusy + events.insert), the skill's whole purpose. - prompt: "We keep getting double-booked when two people pick the same slot — fix it." why: Non-obvious symptom phrasing; the availability race (browser-computed slots / write-before-recheck) this skill owns. - prompt: "Embed Cal.com on the landing page and white-label it so there's no Cal.com branding." why: Embed altitude — self-hosted Cal.com embed for full white-label. - prompt: "Añade reservas de citas a la web y evita solapamientos con mi calendario de Outlook." why: Spanish trigger plus multi-provider sync; routes to the unified-API path, still this skill. - prompt: "Build booking inside our own UI with the Cal.com Booker atom and fire a webhook on each booking." why: Booker atom altitude plus booking webhook lifecycle — both core to this skill. - prompt: "Bookings show the wrong time after the clocks changed last weekend." why: Non-obvious DST/timezone-drift symptom; the stored-wall-clock bug this skill prevents. - prompt: "Set up minimum notice and a 15-minute buffer so nobody books me back-to-back or in 5 minutes." why: Availability rules (buffers + min-notice) computed server-side — part of the core slot flow. should_not_trigger: - prompt: "List and update events on my Google Calendar for an internal dashboard — there's no booking page." route_to: google-workspace why: Raw calendar CRUD with no booking surface; that is the boundary this skill explicitly disclaims. - prompt: "Set up a generic webhook receiver with signature verification and a retry queue for several providers." route_to: webhooks why: Generic inbound-event infrastructure, not the booking-specific lifecycle. - prompt: "When a booking is created, also create a CRM record, post to Slack, and update a Google Sheet." route_to: automation-flows why: Multi-tool orchestration fan-out, not the act of reserving a slot. - prompt: "Charge $50 when someone books a paid appointment." route_to: stripe why: Payment integration, not scheduling. - prompt: "Write and send the booking confirmation email so it doesn't land in spam." route_to: email-connector why: Email sending/deliverability, not the slot/calendar logic. capability: - scenario: "Design availability + booking for a solo consultant on Google Calendar with a public booking page, preventing double-booking and timezone bugs." must_include: - "freebusy.query run server-side across the relevant calendars before any slot is offered." - "Slots computed server-side applying buffers and minimum notice (not in the browser)." - "A narrow OAuth scope (calendar.freebusy / calendar.events.owned / calendar.app.created), not restricted auth/calendar." - "Event written with an explicit timeZone (IANA) and the instant stored in UTC." - "A booking webhook with signature verification and idempotency on the provider event id." - "Reschedule updates the same stored event id and releases the freed slot (no orphaned second event)." - "Re-check freebusy inside the write transaction to close the race." -
README.md 899 B
# Evals — calendar-scheduling `cases.yaml` holds three groups scored by an LLM judge against the rendered skill — there are no live calendar/API calls. `should_trigger` and `should_not_trigger` check routing: each prompt is classified and we confirm this skill owns it, or that it correctly defers to the named sibling (every `route_to` id is a real skill). `capability` checks that SKILL.md plus `references/` contain enough to satisfy the `must_include` rubric for a solo-consultant Google Calendar booking build — freebusy-first slot math, narrow scopes, timezone-correct writes, a verified idempotent webhook, and orphan-free reschedule. Run them through the repo's eval runner, which discovers `evals/cases.yaml` under each skill; there is no standalone harness here. Treat a judge miss as a signal to sharpen the description's triggers/boundary or fill a gap in the body, not as noise.
-
-
references
-
google-calendar-sync.md 3.1 KB
# Google Calendar sync — payloads and edge cases Facts dated 2026-06-02. Source: developers.google.com/workspace/calendar/api. For non-booking calendar work (internal dashboards, raw event CRUD), use the `google-workspace` sibling — this file is the booking-specific subset. ## 1. Availability — `freebusy.query` POST `https://www.googleapis.com/calendar/v3/freeBusy`. Always query every calendar that can block time (primary plus secondary calendars). ```json { "timeMin": "2026-06-02T00:00:00Z", "timeMax": "2026-06-09T00:00:00Z", "timeZone": "Europe/Andorra", "items": [{ "id": "primary" }, { "id": "team-room@group.calendar.google.com" }] } ``` Response returns `calendars.<id>.busy` as an array of `{start, end}` blocks. Compute open slots = working hours − busy − buffers − minimum-notice window, **server-side**. The browser renders the result; it never computes it. ## 2. Write — `events.insert` (with a Meet link) POST `.../calendars/{calendarId}/events?conferenceDataVersion=1`. Set `timeZone` on both `start` and `end`, or the time drifts to the calendar's default zone. ```json { "summary": "Intro call", "start": { "dateTime": "2026-10-25T10:00:00", "timeZone": "Europe/Andorra" }, "end": { "dateTime": "2026-10-25T10:30:00", "timeZone": "Europe/Andorra" }, "attendees": [{ "email": "invitee@example.com" }], "conferenceData": { "createRequest": { "requestId": "uuid-per-booking", "conferenceSolutionKey": { "type": "hangoutsMeet" } } } } ``` Persist the returned event `id` — you need it for reschedule and cancel. ## 3. Reschedule / cancel — no orphans - Reschedule → `events.update` (or PATCH) the **stored event id** with new times. Never `events.insert` a second event. - Cancel → `events.delete` the stored id (or PATCH `status: "cancelled"`), then release any slot lock so the old time is offerable again. ## 4. Stay in sync — `watch` + sync tokens, not polling - **Push channels:** `events.watch` registers a channel that POSTs to your callback URL on changes. Channels expire — renew before expiry. - **Incremental sync:** store the `nextSyncToken` from a list call; pass it as `syncToken` next time to fetch only deltas. A `410 Gone` means the token expired → do a full resync and capture a fresh token. - Polling instead is slower, rate-limited, and misses edits between polls. ## 5. Recurring-event edge cases - A recurring series has one master event; individual occurrences carry a `recurringEventId` pointing back to it. - Editing a single occurrence creates an **exception instance** — update that instance's id, not the master, or you mutate the whole series. - When reading availability, expand instances (`singleEvents=true`) so each occurrence's busy block is counted, not just the master. ## 6. Refresh tokens - The refresh token arrives once (first consent with `access_type=offline`). Store it; access tokens are short-lived and you mint new ones from it. - A `401` on an API call means refresh the access token and retry; a hard refresh failure means re-consent (token revoked or scope changed). -
provider-matrix.md 3.5 KB
# Provider matrix — which scheduling stack wins when Facts dated 2026-06-02. Pick by what you must *own*, not by brand familiarity. ## Cal.com - **Hosting:** open-source, cloud or **self-hostable**. Self-host = full white-label (point the embed script at your own domain, zero Cal.com brand) and **unlimited API access** — no cloud rate limit to design around. - **REST API:** base `https://api.cal.com/v2`. `POST /v2/bookings` creates regular, recurring, and instant bookings with attendee info, metadata, and booking-field responses. - **Booking surfaces:** (a) **embed** — inline / button / modal triggers, identical on cloud and self-hosted; (b) **Booker atom** — an open-source modular component for a fully custom booking UI inside your app; (c) the v2 API for headless booking. - **Webhooks:** `BOOKING_CREATED` / `BOOKING_CANCELLED` / `BOOKING_RESCHEDULED` (plus more). Verify the signature; dedupe on the booking id. - **Wins when:** you want OSS / self-host / white-label, or you need a custom UI (atom) without owning calendar sync, or you want no rate-limit anxiety. ## Calendly - **Auth:** OAuth 2.1 or personal access token. **v1 API and v1 webhooks were discontinued in May 2025** — v2 only for new work (REST, JSON). - **Scheduling API:** the Create Event Invitee endpoint books meetings via API with **no redirect / iframe / hosted UI** — headless booking. - **Single-use scheduling links expire after 90 days** if unused. - **Webhooks:** `invitee.created`, `invitee.canceled`, and routing-form submissions. Scoped `user` or `organization`. **Require a paid plan** (Standard / Teams / Enterprise). - **Wins when:** the org already runs Calendly and you want API booking + events without standing up your own scheduler. ## Google Calendar (direct, single provider) - **Availability:** `freebusy.query` (POST) returns busy blocks per calendar. - **Write:** `events.insert` (add `conferenceData` to attach a Meet link). - **Scopes:** `calendar` / `calendar.events` are **restricted** (require a third-party security assessment for production). Prefer granular: `calendar.app.created`, `calendar.freebusy`, `calendar.events.freebusy`, `calendar.events.owned`, `calendar.readonly`. - **What you own:** OAuth, token refresh, `watch` push channels, incremental sync via sync tokens, recurring-event edge cases. - **Wins when:** one provider, you need direct read/write, and you accept owning the integration. Pure CRUD with no booking surface → use `google-workspace`. ## Nylas v3 (unified) - **Coverage:** Google + Outlook/Exchange + Apple/iCloud behind one API, with availability and free/busy endpoints. - **Caveat:** **domain-scoped** — availability queries are restricted within an organization, so **cross-company scheduling needs multiple external calls**. - **Wins when:** multi-provider but single-org scheduling is fine and you don't want to maintain three integrations. ## Cronofy (unified) - **Coverage:** Google + Outlook/Exchange + Apple/iCloud behind one API. - **No domain restriction:** a single call can span domains; caches free/busy in a Sync Engine. - **Wins when:** multi-provider AND **cross-domain** scheduling matters (booking across companies in one availability call). ## Quick chooser - Fastest page, no UI work → Cal.com / Calendly **embed**. - Own UI + own data model → Cal.com **Booker atom** or **Scheduling API**. - One provider, direct control → **Google direct** (`freebusy.query` + `events.insert`). - Many providers, single org → **Nylas v3**. - Many providers, cross-domain → **Cronofy**.
-
-
scripts
-
verify.sh 4.6 KB
#!/usr/bin/env bash # verify.sh — calendar-scheduling lint. Run inside YOUR project against the # generated scheduling integration. Read-only: it greps, it never edits. # # Usage: # bash scripts/verify.sh [path] # default path: . # # Lints three definite booking hazards (a lint, not a build): # 1. Over-broad Google scope — restricted auth/calendar or auth/calendar.events # requested where a narrower scope (calendar.freebusy, calendar.app.created, # calendar.events.owned) would do. # 2. Timezone hazard — a Google event payload with a dateTime but no timeZone. # 3. Webhook unsafety — a booking webhook handler with neither a signature # verification nor an idempotency key. # # Exit non-zero ONLY on a definite hazard. Empty/clean target -> exit 0. # Prose-only configs (docs, READMEs) pass the scope/timezone checks. # # Env: NO_COLOR=1 disables color. # Portability: stock macOS bash 3.2 and CI bash 5. No mapfile, no bash-4 features. set -euo pipefail ROOT="${1:-.}" if [[ -n "${NO_COLOR:-}" ]]; then YEL=""; GRN=""; RED=""; RST="" else YEL=$'\033[33m'; GRN=$'\033[32m'; RED=$'\033[31m'; RST=$'\033[0m'; fi warn() { printf '%s[skip]%s %s\n' "$YEL" "$RST" "$*"; } ok() { printf '%s[ ok ]%s %s\n' "$GRN" "$RST" "$*"; } fail() { printf '%s[fail]%s %s\n' "$RED" "$RST" "$*"; } if [[ ! -e "$ROOT" ]]; then warn "path not found: $ROOT (nothing to lint)"; exit 0; fi # Collect candidate source files (NUL-delimited; bash-3.2-safe, no mapfile). FILES=() while IFS= read -r -d '' f; do FILES+=("$f") done < <(find "$ROOT" \ \( -path '*/node_modules/*' -o -path '*/vendor/*' -o -path '*/.git/*' \ -o -path '*/dist/*' -o -path '*/build/*' \) -prune \ -o -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' \ -o -name '*.py' -o -name '*.go' -o -name '*.rb' -o -name '*.json' \ -o -name '*.yaml' -o -name '*.yml' -o -name '*.env*' \) -print0 2>/dev/null) if [[ ${#FILES[@]} -eq 0 ]]; then ok "no scheduling source files under $ROOT — nothing to lint" exit 0 fi FAILED=0 # 1. Over-broad Google scope. The restricted scopes are the full-string forms; # do not flag the granular .freebusy/.app.created/.events.owned/.readonly ones. SCOPE_HITS="" for f in ${FILES[@]+"${FILES[@]}"}; do if grep -Eq 'auth/calendar(\.events)?["'\'' ,)]' "$f" 2>/dev/null \ || grep -Eq 'auth/calendar(\.events)?$' "$f" 2>/dev/null; then # exclude lines that are actually a narrower scope if grep -E 'auth/calendar(\.events)?["'\'' ,)$]' "$f" 2>/dev/null \ | grep -Ev 'freebusy|app\.created|events\.owned|readonly|events\.freebusy' >/dev/null 2>&1; then SCOPE_HITS="$SCOPE_HITS $f" fi fi done if [[ -n "$SCOPE_HITS" ]]; then fail "restricted Google scope (auth/calendar or auth/calendar.events) found in:$SCOPE_HITS" fail " -> use calendar.freebusy / calendar.app.created / calendar.events.owned to avoid the security assessment" FAILED=1 else ok "no over-broad Google calendar scopes" fi # 2. Timezone hazard — a Google event payload with dateTime but no timeZone in # the same file. Heuristic: files mentioning dateTime must also mention timeZone. TZ_HITS="" for f in ${FILES[@]+"${FILES[@]}"}; do if grep -q 'dateTime' "$f" 2>/dev/null; then if ! grep -q 'timeZone' "$f" 2>/dev/null; then TZ_HITS="$TZ_HITS $f" fi fi done if [[ -n "$TZ_HITS" ]]; then fail "event payload uses dateTime without timeZone (drifts after DST) in:$TZ_HITS" fail " -> set timeZone (IANA id) on start/end; store the instant in UTC" FAILED=1 else ok "no naive datetime payloads (timeZone present where dateTime is)" fi # 3. Webhook unsafety — a booking webhook handler with neither signature # verification nor an idempotency key. WH_HITS="" for f in ${FILES[@]+"${FILES[@]}"}; do if grep -Eiq 'invitee\.(created|canceled)|BOOKING_(CREATED|CANCELLED|RESCHEDULED)|webhook' "$f" 2>/dev/null; then has_sig=0; has_idem=0 grep -Eiq 'signature|verifySignature|constructEvent|x-cal-signature|x-hook-signature|hmac' "$f" 2>/dev/null && has_sig=1 grep -Eiq 'idempoten|dedupe|event[_.]?id|already[_ ]?processed' "$f" 2>/dev/null && has_idem=1 if [[ $has_sig -eq 0 && $has_idem -eq 0 ]]; then WH_HITS="$WH_HITS $f" fi fi done if [[ -n "$WH_HITS" ]]; then fail "booking webhook handler with no signature verification AND no idempotency key in:$WH_HITS" fail " -> verify the provider signature and dedupe on the provider event id" FAILED=1 else ok "booking webhook handlers verify a signature or dedupe (or none present)" fi echo if [[ $FAILED -ne 0 ]]; then fail "calendar-scheduling lint found definite hazards above" exit 1 fi ok "calendar-scheduling lint clean" exit 0
-
-
SKILL.md 10.3 KB
--- name: calendar-scheduling description: "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`)." tags: [scheduling, booking, calendar, calcom, calendly, google-calendar, availability, webhooks] recommends: [google-workspace, webhooks, automation-flows, email-connector, stripe, sales-pipeline] profiles: [] origin: risco --- # Calendar scheduling — booking surface + calendar sync, shipped together 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`](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. ```ts // 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: 1. **`freebusy.query`** across every relevant calendar (the host's, plus any secondary calendars that block time). Never trust a cached availability blob. 2. **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. 3. **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. 4. **Write the event LAST** — only after the lock is held. 5. **Re-check `freebusy` inside 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`](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 `timeZone`** alongside `dateTime`, or Google interprets the time in the calendar's default zone and the meeting drifts. ```json // 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 fires `BOOKING_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 `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. ## 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 — never `events.insert` a 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`](../google-workspace/SKILL.md); charging for a paid appointment → [`../stripe/SKILL.md`](../stripe/SKILL.md); booking funnel as sales stages → [`../sales-pipeline/SKILL.md`](../sales-pipeline/SKILL.md); sending the confirmation email itself → [`../email-connector/SKILL.md`](../email-connector/SKILL.md).
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.