Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download ericrisco-rsc-harness-skills_calendar-scheduling-953fef5.zip · 12 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/calendar-scheduling
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git 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:

  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.

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.
// 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; 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.

No comments yet.

Reviews (0)

No reviews yet.

Related