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.
- name
calendar-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.
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 (
timestamptzin Postgres) and store the user’s IANA zone (America/New_York, neverESTor-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. Usezoneinfo(Python),Temporalordate-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
| Need | Choose | Why |
|---|---|---|
| Book time with your team, standard flows | Cal.com (hosted or self-hosted) embed + webhooks | Handles calendar connections, routing, rescheduling, reminders |
| Bookings are your core domain (clinics, rentals, classes) | Own booking table + provider calendar sync | You need custom rules, resources, payments, capacity |
| Only “add to calendar” for events you already have | Generate ICS + Google/Outlook links | No OAuth, no sync to maintain |
| Read a user’s busy time to avoid conflicts | Google Calendar freeBusy / Microsoft Graph getSchedule | Busy 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):
- Expand working hours for each local date in range in the host’s zone
(
09:00-17:00 Europe/Berlinon each date, so DST is respected per day). - Subtract busy intervals: existing bookings + external calendar busy blocks.
- Expand each busy block by buffers (before/after), then subtract.
- Apply minimum notice (
now + 4h) and maximum horizon (now + 60d). - Step through free intervals at the slot interval (often = duration, or 15 min) and keep slots whose full duration fits.
- 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, headerAuthorization: Bearer <key>(keys start withcal_for test andcal_live_for live). - Each endpoint family is versioned by a
cal-api-versionheader. Slots use2024-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:
| Property | Rule |
|---|---|
UID | Generate once per booking, globally unique (uuid@yourdomain), reuse forever |
SEQUENCE | 0 on create; increment on every reschedule or edit |
DTSTAMP | Required, UTC, time the object was generated |
METHOD | REQUEST for emailed invites (needs ORGANIZER), CANCEL to cancel, PUBLISH for download-only |
| Times | UTC (...Z) is simplest; TZID= requires a matching VTIMEZONE in the file |
| Text | Escape \, ;, ,, and newlines as \n |
| Lines | CRLF 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 setssent_atin the same transaction as the claim, so two workers never double-send. - Compute
send_atfrom 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().timeZoneis the right default for the booker’s zone; let them change it and persist the choice. - All-day events are
DATEvalues (DTSTART;VALUE=DATE:20261103) with exclusiveDTEND. - Outlook rejects or duplicates invites with changed UIDs, missing
ORGANIZERonREQUEST, or bare LF line endings. - Recurring series: modifying one occurrence needs
RECURRENCE-ID; avoid building your own RRULE engine (usepython-dateutilrruleorrrule.js). - Google
freeBusyreturns 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
23P01mapped to a friendly error. - Run
python3 scripts/make_ics.py checkon 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
#!/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.