Calendar Scheduling skill for Claude Code

Bookings that survive DST, races, and Outlook. Claude models scheduling the way calendar engineers do: UTC instants with IANA zones, slots computed from working hours minus busy time and buffers, a Postgres exclusion constraint that makes double-booking impossible, Cal.com bookings with verified webhooks, and RFC 5545 invites with stable UIDs and SEQUENCE. A bundled script generates and lints .ics files.

  • namecalendar-scheduling
  • size237 lines · ≈2,900 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds scheduling features that survive time zones and DST - storing instants in UTC with the IANA zone beside them, availability and slot computation with buffers and busy-time lookups, Cal.com API v2 bookings and signed webhooks, Google Calendar freeBusy, RFC 5545 ICS invites with stable UIDs and SEQUENCE, and reminder jobs that do not double-send. Use when adding booking or appointment flows, embedding Cal.com, generating .ics attachments, computing open slots, or debugging events that land an hour off.

Try asking

  • Build an appointment booking page with 30-minute slots, 10-minute buffers, and no double-booking
  • Our meeting invites show up an hour off after daylight saving changed. Find and fix it
  • Send an .ics invite on booking and make reschedules update the same calendar event

Use it when

  • Adding appointment, demo, or class booking to an app
  • Embedding Cal.com or consuming its webhooks
  • Generating .ics attachments or add-to-calendar links
  • Events or reminders land at the wrong local time around DST

Skip it when

  • You need Google Calendar OAuth setup, scopes, and quotas themselves; use google-workspace-apis instead
  • The calendar lives in Outlook or Exchange; use microsoft-graph for the API calls

How to check the result

Slot tests pass on spring-forward and fall-back dates, 20 concurrent bookings for one slot yield exactly one success, and a rescheduled invite with the same UID and higher SEQUENCE updates the original event in Google, Apple, and Outlook.

The common mistake it prevents

Storing a fixed UTC offset instead of an IANA zone, so recurring meetings and reminders shift by an hour the moment daylight saving starts or ends.

~/.claude/skills/calendar-scheduling/SKILL.md

--- name: calendar-scheduling description: Builds scheduling features that survive time zones and DST - storing instants in UTC with the IANA zone beside them, availability and slot… license: MIT ---

Done well means: every stored time is an unambiguous instant plus the IANA zone it was chosen in, slots are computed against real busy data at booking time (not only at display time), double-booking is impossible under concurrency, invites update in place in every calendar client, and reminders fire once at the right local moment, including across a DST change.

Ground rules

  • Store instants as UTC (timestamptz in Postgres) and store the user’s IANA zone (America/New_York, never EST or -05:00) in a separate column. Offsets are not zones; they change twice a year.
  • For recurring or future local-time intent (“every Monday 09:00 Berlin”), store the local wall time + zone + rule, and expand to instants late. A UTC instant computed today for a meeting next April is wrong once DST rules change.
  • Never do date arithmetic on formatted strings or with fixed 86400-second days. Use zoneinfo (Python), Temporal or date-fns-tz/Luxon (JS), java.time.
  • Re-check availability inside the booking transaction. The slot list the client saw is a hint.
  • API keys (CAL_API_KEY), OAuth refresh tokens, and webhook secrets come from env or a secret manager. Never commit them; never send them to the browser.

Workflow

- [ ] 1. Decide build vs. embed (table below) and the source of truth for bookings
- [ ] 2. Model times: UTC instants + IANA zone; local rules for recurrence
- [ ] 3. Availability: working hours -> minus busy -> minus buffers -> slots
- [ ] 4. Booking: transactional re-check + exclusion constraint against overlaps
- [ ] 5. Notifications: ICS invite (stable UID), confirmation email, reminders
- [ ] 6. Sync: webhooks in (verified), changes out (update by UID/external id)
- [ ] 7. Verify across DST boundaries and two zones

1. Build vs. embed

NeedChooseWhy
Book time with your team, standard flowsCal.com (hosted or self-hosted) embed + webhooksHandles calendar connections, routing, rescheduling, reminders
Bookings are your core domain (clinics, rentals, classes)Own booking table + provider calendar syncYou need custom rules, resources, payments, capacity
Only “add to calendar” for events you already haveGenerate ICS + Google/Outlook linksNo OAuth, no sync to maintain
Read a user’s busy time to avoid conflictsGoogle Calendar freeBusy / Microsoft Graph getScheduleBusy blocks only, smallest scope

For Google Calendar OAuth, scopes, and quotas see the google-workspace-apis skill; for Outlook calendars see microsoft-graph. This skill covers the scheduling logic on top.

2. Data model

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE booking (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  host_id      uuid NOT NULL,
  starts_at    timestamptz NOT NULL,
  ends_at      timestamptz NOT NULL CHECK (ends_at > starts_at),
  booker_tz    text NOT NULL,            -- IANA zone shown to the booker
  ical_uid     text NOT NULL UNIQUE,     -- stable across reschedules
  ical_seq     int  NOT NULL DEFAULT 0,  -- bump on every change
  status       text NOT NULL DEFAULT 'confirmed',
  external_ref text,                     -- Cal.com booking uid / Google event id
  -- Database-level guarantee: no overlapping confirmed bookings per host.
  EXCLUDE USING gist (host_id WITH =, tstzrange(starts_at, ends_at, '[)') WITH &&)
    WHERE (status = 'confirmed')
);

The exclusion constraint is the double-booking guard. Application checks race; the constraint does not. Catch the violation (SQLSTATE 23P01) and return “slot just taken”.

3. Slot computation

Order of operations (do it in the host’s zone, output instants):

  1. Expand working hours for each local date in range in the host’s zone (09:00-17:00 Europe/Berlin on each date, so DST is respected per day).
  2. Subtract busy intervals: existing bookings + external calendar busy blocks.
  3. Expand each busy block by buffers (before/after), then subtract.
  4. Apply minimum notice (now + 4h) and maximum horizon (now + 60d).
  5. Step through free intervals at the slot interval (often = duration, or 15 min) and keep slots whose full duration fits.
  6. Return ISO 8601 UTC instants; the client renders them in the booker’s zone.
from datetime import datetime, date, time, timedelta
from zoneinfo import ZoneInfo

def day_window(d: date, start: time, end: time, tz: str):
    z = ZoneInfo(tz)
    return datetime.combine(d, start, z), datetime.combine(d, end, z)   # aware, DST-correct

def subtract(free, busy):
    out = []
    for fs, fe in free:
        cur = fs
        for bs, be in sorted(busy):
            if be <= cur or bs >= fe:
                continue
            if bs > cur:
                out.append((cur, bs))
            cur = max(cur, be)
        if cur < fe:
            out.append((cur, fe))
    return out

Gotchas in slot math:

  • A local time can occur twice (fall back) or not at all (spring forward). Decide policy: skip nonexistent times; for ambiguous ones use fold=0 (the first occurrence) consistently.
  • “Days” in the booker’s zone and the host’s zone differ. Query busy data for the union range padded by a day on each side.
  • Cache external busy data for at most a minute or two for display; always re-query (or rely on the DB constraint plus a fresh provider check) at booking time.

4. Cal.com API v2

  • Base URL https://api.cal.com/v2, header Authorization: Bearer <key> (keys start with cal_ for test and cal_live_ for live).
  • Each endpoint family is versioned by a cal-api-version header. Slots use 2024-09-04; omitting the header silently falls back to older behavior. Check the endpoint page for the current value before coding against bookings or event types.
  • Default limit is about 120 requests/minute per key; back off on 429.
curl -s "https://api.cal.com/v2/slots?eventTypeId=$EVENT_TYPE_ID&start=2026-11-03&end=2026-11-07&timeZone=Europe/Berlin&format=range" \
  -H "Authorization: Bearer $CAL_API_KEY" -H "cal-api-version: 2024-09-04"

Bookings are created with POST /v2/bookings (start as UTC ISO 8601, eventTypeId, and an attendee with name, email, and IANA timeZone). Store the returned booking uid as external_ref. Prefer the embed (@calcom/embed-react or the inline snippet) when you do not need a custom UI.

5. Webhooks (Cal.com and others)

Cal.com signs the raw body with HMAC-SHA256 using the webhook secret and sends the hex digest in x-cal-signature-256. Payloads look like { triggerEvent, createdAt, payload: {...} }; MEETING_STARTED/MEETING_ENDED are flat.

import crypto from "node:crypto";

export async function POST(req: Request) {
  const raw = Buffer.from(await req.arrayBuffer());          // raw bytes, before JSON.parse
  const sig = req.headers.get("x-cal-signature-256") ?? "";
  const expected = crypto.createHmac("sha256", process.env.CAL_WEBHOOK_SECRET!).update(raw).digest("hex");
  const a = Buffer.from(sig, "hex"), b = Buffer.from(expected, "hex");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return new Response("bad signature", { status: 401 });

  const evt = JSON.parse(raw.toString("utf8"));
  const uid = evt.payload?.uid ?? evt.uid;
  await enqueue({ type: evt.triggerEvent, uid, createdAt: evt.createdAt });  // process async
  return new Response("ok");
}

Handle BOOKING_CREATED, BOOKING_RESCHEDULED (new uid; link via the reschedule fields to the old booking), BOOKING_CANCELLED, BOOKING_REQUESTED (needs confirmation). Deduplicate on (triggerEvent, uid, createdAt); deliveries can repeat. For generic receiver mechanics (queues, replay windows) see the webhook-handling skill.

6. ICS invites

Rules that make invites update instead of duplicating:

PropertyRule
UIDGenerate once per booking, globally unique (uuid@yourdomain), reuse forever
SEQUENCE0 on create; increment on every reschedule or edit
DTSTAMPRequired, UTC, time the object was generated
METHODREQUEST for emailed invites (needs ORGANIZER), CANCEL to cancel, PUBLISH for download-only
TimesUTC (...Z) is simplest; TZID= requires a matching VTIMEZONE in the file
TextEscape \, ;, ,, and newlines as \n
LinesCRLF endings, folded at 75 octets

Email it as Content-Type: text/calendar; method=REQUEST; charset=UTF-8 (inline part plus an .ics attachment for older clients). Use the bundled generator and linter:

python3 scripts/make_ics.py build --summary "Intro call" --start 2026-11-03T15:00 \
  --duration 30 --tz America/New_York --organizer [email protected] \
  --attendee [email protected] --alarm 15 --method REQUEST --uid "$BOOKING_UID@example.com" -o invite.ics
python3 scripts/make_ics.py check invite.ics          # exit 1 on spec errors
# Reschedule: same --uid, --sequence 1. Cancel: same --uid, --sequence 2 --cancel

--keep-tzid emits local times plus a generated VTIMEZONE for the event’s year; use it when the event should display in the organizer’s zone even on clients that lack zone data.

“Add to Google Calendar” link (no OAuth): https://calendar.google.com/calendar/render?action=TEMPLATE&text=...&dates=20261103T200000Z/20261103T203000Z&details=...&location=... (URL-encode every value).

7. Reminders

  • Store reminder rows (booking_id, kind, send_at timestamptz, sent_at) when the booking is created or rescheduled; delete or supersede them on cancel/reschedule.
  • A worker selects due rows with FOR UPDATE SKIP LOCKED, sends, and sets sent_at in the same transaction as the claim, so two workers never double-send.
  • Compute send_at from the booking instant (starts_at - 24h), which is DST-safe. For “the morning of” reminders compute 08:00 in the booker’s zone on the local date.
  • Respect quiet hours in the recipient’s zone for SMS.

Gotchas

  • new Date("2026-11-03") is UTC midnight; new Date("2026-11-03T15:00") is local to the server. Always parse with an explicit zone.
  • Server time zone leaks: run servers in UTC and never call naive datetime.now().
  • Browser Intl.DateTimeFormat().resolvedOptions().timeZone is the right default for the booker’s zone; let them change it and persist the choice.
  • All-day events are DATE values (DTSTART;VALUE=DATE:20261103) with exclusive DTEND.
  • Outlook rejects or duplicates invites with changed UIDs, missing ORGANIZER on REQUEST, or bare LF line endings.
  • Recurring series: modifying one occurrence needs RECURRENCE-ID; avoid building your own RRULE engine (use python-dateutil rrule or rrule.js).
  • Google freeBusy returns busy blocks only; private event details stay hidden, which is the point. Request only that scope when that is all you need.

Verification

  • Unit-test slot generation for: a spring-forward date, a fall-back date, a host and booker on opposite sides of the date line, and a buffer that straddles midnight.
  • Fire 20 concurrent bookings for one slot; exactly one succeeds and the rest get 23P01 mapped to a friendly error.
  • Run python3 scripts/make_ics.py check on every generated invite in CI; import one into Google Calendar, Apple Calendar, and Outlook, then send the reschedule and confirm it updates the same event.
  • Send a webhook with a tampered body: expect 401. Replay a valid one: expect a single processed booking.
  • Freeze time in tests (freezegun, vi.setSystemTime) to assert reminders schedule at the right instant before and after a DST change.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/make_ics.py 288 lines · 11.6 KB
#!/usr/bin/env python3
"""Build or lint RFC 5545 iCalendar (.ics) files. Standard library only (Python 3.10+).

Build an event (UTC times by default; --keep-tzid emits TZID + a generated VTIMEZONE):
  python3 make_ics.py build --summary "Design review" --start 2026-11-03T15:00 \
      --duration 45 --tz America/New_York --organizer [email protected] \
      --attendee [email protected] --alarm 15 --method REQUEST -o invite.ics

Lint an existing file (exit 1 on errors):
  python3 make_ics.py check invite.ics
"""
from __future__ import annotations

import argparse
import re
import sys
import uuid
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

CRLF = "\r\n"


def escape_text(value: str) -> str:
    """Escape a TEXT value per RFC 5545 section 3.3.11."""
    return (
        value.replace("\\", "\\\\")
        .replace(";", "\\;")
        .replace(",", "\\,")
        .replace("\r\n", "\\n")
        .replace("\n", "\\n")
    )


def fold(line: str) -> str:
    """Fold a content line to at most 75 octets per physical line, never splitting a UTF-8 char."""
    out, current, size = [], "", 0
    limit = 75
    for ch in line:
        n = len(ch.encode("utf-8"))
        if size + n > limit:
            out.append(current)
            current, size = " " + ch, 1 + n  # continuation lines start with one space
        else:
            current += ch
            size += n
    out.append(current)
    return CRLF.join(out)


def fmt_utc(dt: datetime) -> str:
    return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def fmt_local(dt: datetime) -> str:
    return dt.strftime("%Y%m%dT%H%M%S")


def fmt_offset(td: timedelta) -> str:
    total = int(td.total_seconds())
    sign = "+" if total >= 0 else "-"
    total = abs(total)
    return f"{sign}{total // 3600:02d}{(total % 3600) // 60:02d}"


def vtimezone(tz: ZoneInfo, year: int) -> list[str]:
    """Generate a VTIMEZONE with explicit (non-recurring) transitions for one year."""
    lines = ["BEGIN:VTIMEZONE", f"TZID:{tz.key}"]
    start = datetime(year, 1, 1, tzinfo=timezone.utc)
    prev = start.astimezone(tz)
    transitions = []
    t = start
    while t.year == year:
        nxt = t + timedelta(hours=1)
        cur = nxt.astimezone(tz)
        if cur.utcoffset() != prev.utcoffset():
            transitions.append((prev, cur))
        prev, t = cur, nxt
    if not transitions:
        off = fmt_offset(prev.utcoffset())
        lines += ["BEGIN:STANDARD", "DTSTART:19700101T000000", f"TZOFFSETFROM:{off}",
                  f"TZOFFSETTO:{off}", f"TZNAME:{prev.tzname()}", "END:STANDARD"]
    for before, after in transitions:
        kind = "DAYLIGHT" if after.dst() else "STANDARD"
        # DTSTART of an observance is expressed in the local time in effect *before* it.
        local_start = (after.astimezone(timezone.utc) + before.utcoffset()).replace(tzinfo=None)
        lines += [f"BEGIN:{kind}", f"DTSTART:{fmt_local(local_start)}",
                  f"TZOFFSETFROM:{fmt_offset(before.utcoffset())}",
                  f"TZOFFSETTO:{fmt_offset(after.utcoffset())}",
                  f"TZNAME:{after.tzname()}", f"END:{kind}"]
    lines.append("END:VTIMEZONE")
    return lines


def build(args: argparse.Namespace) -> int:
    try:
        tz = ZoneInfo(args.tz)
    except ZoneInfoNotFoundError:
        print(f"error: unknown IANA time zone {args.tz!r}", file=sys.stderr)
        return 1
    start = datetime.fromisoformat(args.start)
    start = start.replace(tzinfo=tz) if start.tzinfo is None else start.astimezone(tz)
    if args.end:
        end = datetime.fromisoformat(args.end)
        end = end.replace(tzinfo=tz) if end.tzinfo is None else end.astimezone(tz)
    else:
        end = start + timedelta(minutes=args.duration)
    if end <= start:
        print("error: end must be after start", file=sys.stderr)
        return 1
    if args.method == "REQUEST" and not args.organizer:
        print("error: METHOD:REQUEST requires --organizer", file=sys.stderr)
        return 1

    method = "CANCEL" if args.cancel else args.method
    lines = ["BEGIN:VCALENDAR", "VERSION:2.0", f"PRODID:{args.prodid}", "CALSCALE:GREGORIAN"]
    if method:
        lines.append(f"METHOD:{method}")
    if args.keep_tzid:
        lines += vtimezone(tz, start.year)
    uid = args.uid or f"{uuid.uuid4()}@{args.domain}"
    lines += ["BEGIN:VEVENT", f"UID:{uid}", f"DTSTAMP:{fmt_utc(datetime.now(timezone.utc))}"]
    if args.keep_tzid:
        lines += [f"DTSTART;TZID={tz.key}:{fmt_local(start)}", f"DTEND;TZID={tz.key}:{fmt_local(end)}"]
    else:
        lines += [f"DTSTART:{fmt_utc(start)}", f"DTEND:{fmt_utc(end)}"]
    lines += [f"SEQUENCE:{args.sequence}", f"SUMMARY:{escape_text(args.summary)}"]
    if args.description:
        lines.append(f"DESCRIPTION:{escape_text(args.description)}")
    if args.location:
        lines.append(f"LOCATION:{escape_text(args.location)}")
    if args.url:
        lines.append(f"URL:{args.url}")
    if args.organizer:
        lines.append(f"ORGANIZER:mailto:{args.organizer}")
    for a in args.attendee or []:
        lines.append(f"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{a}")
    lines.append(f"STATUS:{'CANCELLED' if args.cancel else 'CONFIRMED'}")
    if args.alarm is not None and not args.cancel:
        lines += ["BEGIN:VALARM", "ACTION:DISPLAY", f"DESCRIPTION:{escape_text(args.summary)}",
                  f"TRIGGER:-PT{args.alarm}M", "END:VALARM"]
    lines += ["END:VEVENT", "END:VCALENDAR"]

    body = CRLF.join(fold(l) for l in lines) + CRLF
    if args.output:
        with open(args.output, "w", encoding="utf-8", newline="") as fh:
            fh.write(body)
        print(f"wrote {args.output} (UID {uid})", file=sys.stderr)
    else:
        sys.stdout.write(body)
    return 0


DT_RE = re.compile(r"^(\d{8})(T(\d{6})(Z)?)?$")


def parse_dt(params: dict, value: str) -> datetime | None:
    m = DT_RE.match(value)
    if not m:
        return None
    if not m.group(2):
        return datetime.strptime(m.group(1), "%Y%m%d").replace(tzinfo=timezone.utc)
    dt = datetime.strptime(m.group(1) + m.group(3), "%Y%m%d%H%M%S")
    if m.group(4):
        return dt.replace(tzinfo=timezone.utc)
    tzid = params.get("TZID")
    if tzid:
        try:
            return dt.replace(tzinfo=ZoneInfo(tzid))
        except ZoneInfoNotFoundError:
            return dt.replace(tzinfo=timezone.utc)
    return dt.replace(tzinfo=timezone.utc)  # floating time; compare naively


def check(args: argparse.Namespace) -> int:
    raw = open(args.file, "rb").read()
    errors, warnings = [], []
    text = raw.decode("utf-8", errors="replace")
    if b"\r\n" not in raw:
        errors.append("lines must end with CRLF")
    if re.search(rb"(?<!\r)\n", raw):
        warnings.append("found bare LF line endings (some clients tolerate, Outlook may not)")
    physical = re.split(r"\r?\n", text)
    for i, line in enumerate(physical, 1):
        if len(line.encode("utf-8")) > 75:
            errors.append(f"physical line {i} is {len(line.encode('utf-8'))} octets (max 75; fold it)")
    # unfold
    logical = []
    for line in physical:
        if line.startswith((" ", "\t")) and logical:
            logical[-1] += line[1:]
        elif line:
            logical.append(line)

    stack, events, tzids_defined, tzids_used = [], [], set(), set()
    cal_props, method = set(), None
    for line in logical:
        name_part, _, value = line.partition(":")
        name, *param_parts = name_part.split(";")
        name = name.upper()
        params = dict(p.split("=", 1) for p in param_parts if "=" in p)
        if name == "BEGIN":
            stack.append(value.upper())
            if value.upper() == "VEVENT":
                events.append({})
            continue
        if name == "END":
            if not stack or stack[-1] != value.upper():
                errors.append(f"END:{value} does not match BEGIN:{stack[-1] if stack else '(none)'}")
            else:
                stack.pop()
            continue
        if stack == ["VCALENDAR"]:
            cal_props.add(name)
            if name == "METHOD":
                method = value.upper()
        if stack and stack[-1] == "VTIMEZONE" and name == "TZID":
            tzids_defined.add(value)
        if "TZID" in params:
            tzids_used.add(params["TZID"])
        if stack and stack[-1] == "VEVENT":
            events[-1].setdefault(name, []).append((params, value))
    if stack:
        errors.append(f"unclosed components: {stack}")
    for req in ("VERSION", "PRODID"):
        if req not in cal_props:
            errors.append(f"VCALENDAR missing {req}")
    for tzid in sorted(tzids_used - tzids_defined):
        errors.append(f"TZID={tzid} used but no matching VTIMEZONE")
    if not events:
        errors.append("no VEVENT found")
    for n, ev in enumerate(events, 1):
        label = f"VEVENT #{n}"
        for req in ("UID", "DTSTAMP", "DTSTART"):
            if req not in ev:
                errors.append(f"{label} missing {req}")
        if "DTEND" in ev and "DURATION" in ev:
            errors.append(f"{label} has both DTEND and DURATION")
        if method == "REQUEST" and "ORGANIZER" not in ev:
            errors.append(f"{label} METHOD:REQUEST requires ORGANIZER")
        if "DTSTART" in ev and "DTEND" in ev:
            s = parse_dt(*ev["DTSTART"][0])
            e = parse_dt(*ev["DTEND"][0])
            if s and e and e <= s:
                errors.append(f"{label} DTEND is not after DTSTART")
        for prop in ("SUMMARY", "DESCRIPTION", "LOCATION"):
            for _, v in ev.get(prop, []):
                if re.search(r"(?<!\\)[,;]", v):
                    warnings.append(f"{label} {prop} has unescaped ',' or ';'")
    for w in warnings:
        print(f"warn: {w}")
    for e in errors:
        print(f"error: {e}")
    print(f"{args.file}: {len(events)} event(s), {len(errors)} error(s), {len(warnings)} warning(s)")
    return 1 if errors else 0


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)
    b = sub.add_parser("build", help="write a single-event .ics")
    b.add_argument("--summary", required=True)
    b.add_argument("--start", required=True, help="ISO 8601 local time in --tz, e.g. 2026-11-03T15:00")
    b.add_argument("--end", help="ISO 8601 end; alternatively use --duration")
    b.add_argument("--duration", type=int, default=30, help="minutes (default 30)")
    b.add_argument("--tz", default="UTC", help="IANA zone of --start/--end (default UTC)")
    b.add_argument("--keep-tzid", action="store_true", help="emit TZID times plus a VTIMEZONE instead of UTC")
    b.add_argument("--uid", help="stable UID; reuse it for updates and cancellations")
    b.add_argument("--domain", default="example.invalid", help="domain suffix for generated UIDs")
    b.add_argument("--sequence", type=int, default=0, help="increment on every update to the same UID")
    b.add_argument("--description")
    b.add_argument("--location")
    b.add_argument("--url")
    b.add_argument("--organizer", help="organizer email")
    b.add_argument("--attendee", action="append", help="attendee email (repeatable)")
    b.add_argument("--alarm", type=int, help="VALARM minutes before start")
    b.add_argument("--method", choices=["PUBLISH", "REQUEST"], help="iTIP method (REQUEST for email invites)")
    b.add_argument("--cancel", action="store_true", help="emit METHOD:CANCEL / STATUS:CANCELLED")
    b.add_argument("--prodid", default="-//example//scheduling//EN")
    b.add_argument("-o", "--output")
    c = sub.add_parser("check", help="lint an existing .ics")
    c.add_argument("file")
    args = p.parse_args()
    return build(args) if args.cmd == "build" else check(args)


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc