Calendar Integrations skill for Codex
Events at the right time, in every calendar app. Codex stores calendar times as UTC plus an IANA zone, treats all-day ends as exclusive dates, and syncs Google Calendar incrementally with syncToken and a clean recovery from 410 errors. It writes .ics invites with stable UIDs and SEQUENCE bumps, lints them with a bundled checker, handles CalDAV with ETags, and never emails attendees without approval.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- calendar-integrations Installs to ~/.agents/skills/calendar-integrations/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install calendar-integrations The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project calendar-integrations Installs to .agents/skills/calendar-integrations/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/calendar-integrations.zipunzip -o calendar-integrations.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) calendar-integrations PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $calendar-integrations, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Integrates apps with calendars - Google Calendar API v3 (events, freeBusy, incremental sync with syncToken, push channels), CalDAV servers such as iCloud and Fastmail, and RFC 5545 .ics feeds and invites - with correct time zones, recurrence, all-day events and attendee notifications. Use when building booking or scheduling features, two-way calendar sync, availability checks, ICS export or email invites, or debugging events that land at the wrong time or duplicate.
Use it when
- Building booking, scheduling or availability features
- Syncing events with Google Calendar or a CalDAV server such as iCloud
- Generating .ics downloads, subscription feeds or email invites
- Events appear shifted by an hour or duplicate after sync
Use something else when
- The calendars are Outlook or Microsoft 365: use microsoft-graph
- You are scheduling server jobs rather than human events: use cron-scheduling
How to check the result
An event created after the next DST change shows the same local time in the app and in Google and Apple Calendar, and every generated .ics passes the bundled lint script.
The mistake it prevents
Treating an all-day event's end date as inclusive, so one-day events vanish or two-day events shrink.
Done well means: every stored time has an IANA zone or is UTC, recurring events are expanded by a
library rather than by hand, sync is incremental and survives token resets, generated .ics files
import cleanly into Google, Outlook and Apple Calendar, and no attendee gets an email the user did
not approve.
Inspect first
- Read
AGENTS.md; find existing code withrg -n "googleapis|calendar|caldav|ical|icalendar|rrule|ics" --glob '!node_modules'. - Identify the direction: read-only availability, one-way publish (app -> calendar), or two-way sync. Two-way sync is several times the work; confirm it is really needed.
- Check how times are stored today (timestamp with zone, naive local, strings) before adding fields.
- Outlook / Microsoft 365 calendars go through Microsoft Graph; hand off to
$microsoft-graph.
Pick the integration path
| Need | Path |
|---|---|
| Users connect Google accounts | Google Calendar API v3 with OAuth (per-user tokens) |
| Workspace-wide access to company calendars | Service account with domain-wide delegation (admin must grant) |
| iCloud, Fastmail, Nextcloud, self-hosted | CalDAV (caldav for Python, tsdav for Node) |
| “Add to calendar” button or email invite | Generate .ics (icalendar for Python, ical-generator for Node) |
| Subscribable feed of app events | Public or secret-token .ics URL served with text/calendar |
| Expand RRULEs from any source | python-dateutil rrulestr + recurring-ical-events, or rrule in JS |
Time rules (apply everywhere)
- Store instants as UTC plus the event’s IANA zone (
America/New_York), never a fixed offset; offsets change with DST and recurrence must follow the zone. - All-day events are dates, not midnight timestamps. The end date is exclusive: a one-day event on
2 Nov has
DTSTART;VALUE=DATE:20261102andDTEND;VALUE=DATE:20261103; Google usesstart.dateandend.datethe same way. - Floating times (no zone) mean “same wall clock everywhere”; avoid them unless that is intended.
- Show times in the viewer’s zone; schedule in the organiser’s zone.
Google Calendar API v3
Scopes, narrowest first: calendar.freebusy (busy blocks only), calendar.events.readonly,
calendar.events, calendar (also manages calendars). Request the smallest that works; broad
scopes trigger stricter OAuth verification. OAuth details are in $oauth-integrations.
from googleapiclient.discovery import build
svc = build("calendar", "v3", credentials=creds)
busy = svc.freebusy().query(body={
"timeMin": "2026-11-02T00:00:00Z", "timeMax": "2026-11-09T00:00:00Z",
"items": [{"id": "primary"}],
}).execute()["calendars"]["primary"]["busy"]
event = svc.events().insert(
calendarId="primary",
sendUpdates="none", # "all" emails attendees: only after user confirmation
conferenceDataVersion=1,
body={
"summary": "Intro call",
"start": {"dateTime": "2026-11-03T15:00:00", "timeZone": "Europe/Berlin"},
"end": {"dateTime": "2026-11-03T15:30:00", "timeZone": "Europe/Berlin"},
"attendees": [{"email": "[email protected]"}],
"conferenceData": {"createRequest": {"requestId": booking_id}},
"extendedProperties": {"private": {"bookingId": booking_id}},
},
).execute()
- Save
event["id"]and tag events withextendedProperties.privateso you can find your own events later (privateExtendedProperty=bookingId=...filter onevents.list). - Use a deterministic
id(base32hex, 5-1024 chars) orrequestIdto make retries idempotent; a duplicateidreturns 409 instead of a second event. sendUpdates="all"sends real emails. Ask the user before enabling it on any non-test calendar.
Incremental sync
def sync(cal_id, token=None):
params = {"calendarId": cal_id, "singleEvents": False, "showDeleted": True}
if token:
params["syncToken"] = token
else:
params["timeMin"] = "2026-01-01T00:00:00Z" # first full sync window
while True:
try:
resp = svc.events().list(**params).execute()
except HttpError as e:
if e.resp.status == 410: # token expired: wipe local copy, full resync
return sync(cal_id, None)
raise
for ev in resp.get("items", []):
upsert_or_delete(ev) # status == "cancelled" means deleted
if "nextPageToken" in resp:
params["pageToken"] = resp["nextPageToken"]
else:
return resp["nextSyncToken"] # persist per calendar
syncTokencannot be combined withtimeMin,timeMax,qor most filters; do the first sync with them, later syncs with the token only.- Recurring series arrive as one master plus exceptions (
recurringEventId,originalStartTime). UsesingleEvents=Trueonly for display windows, not for sync.
Push notifications
events.watch registers an HTTPS webhook (type: "web_hook", address, unique id, optional
token). Notifications carry headers only: X-Goog-Channel-ID, X-Goog-Resource-State
(sync on creation, then exists/not_exists) and X-Goog-Channel-Token. On each ping, run
the incremental sync above. Channels expire (expiration in the response); renew before expiry
with a new channel and channels.stop the old one. Test locally with $webhook-testing-tools.
CalDAV
import caldav
with caldav.DAVClient(url="https://caldav.icloud.com/", username=user, password=app_password) as client:
cal = client.principal().calendars()[0]
for ev in cal.search(start=start, end=end, event=True, expand=True):
print(ev.icalendar_component["SUMMARY"])
cal.save_event(ics_text)
- iCloud and Fastmail require app-specific passwords, not the account password; never ask for the main password.
- Track each resource’s URL and
ETag; update withIf-Matchto avoid overwriting a concurrent edit (412 means re-fetch and merge). Use the collectiongetctag/sync-tokento skip unchanged calendars.
Generating .ics files and invites
from icalendar import Calendar, Event
from datetime import datetime
from zoneinfo import ZoneInfo
cal = Calendar()
cal.add("prodid", "-//Acme//Bookings//EN")
cal.add("version", "2.0")
cal.add("method", "REQUEST") # invite; use PUBLISH for plain downloads
ev = Event()
ev.add("uid", f"booking-{booking_id}@acme.example") # stable across updates
ev.add("dtstamp", datetime.now(ZoneInfo("UTC")))
ev.add("dtstart", datetime(2026, 11, 3, 15, 0, tzinfo=ZoneInfo("Europe/Berlin")))
ev.add("dtend", datetime(2026, 11, 3, 15, 30, tzinfo=ZoneInfo("Europe/Berlin")))
ev.add("summary", "Intro call")
ev.add("organizer", "mailto:[email protected]")
ev.add("attendee", "mailto:[email protected]", parameters={"RSVP": "TRUE"})
ev.add("sequence", 0) # increment on every update
cal.add_component(ev)
cal.add_missing_timezones() # icalendar 6+: emits VTIMEZONE blocks
open("invite.ics", "wb").write(cal.to_ical())
- Updates reuse the same
UIDwith a higherSEQUENCE; cancellations sendMETHOD:CANCELandSTATUS:CANCELLEDwith the sameUID. - Email invites: attach as
text/calendar; method=REQUEST; charset=UTF-8. Sending mail is outward-facing; confirm recipients with the user first. - Lint every generated file:
python3 scripts/lint_ics.py invite.ics
It checks CRLF endings, line folding, required VERSION/PRODID/UID/DTSTAMP/DTSTART,
balanced components, duplicate UIDs, exclusive all-day ends and TZIDs without VTIMEZONE.
Exit code 1 means at least one error.
Verification
- Create an event at 09:00 in a DST-shifting zone for a date after the next DST change; it shows 09:00 local in Google Calendar and in the app.
- Generated
.icspasseslint_ics.pyand imports into Google Calendar and Apple Calendar with the same time. - Editing and deleting an event in the external calendar appears in the app after one sync.
- Forcing a 410 (clear the stored token) triggers a full resync without duplicates.
- Retrying a create with the same idempotency key produces one event.
Gotchas
dateTimewithouttimeZoneor an offset is rejected or misread; always send one.- Google returns cancelled instances of recurring events as exceptions; treat
status: cancelledas a delete even without other fields. - Free/busy for other users only works if they share at least free/busy visibility.
- Outlook ignores
.icsupdates whoseSEQUENCEdid not increase. - Deleting events or calendars on a user’s account is destructive; confirm with the user and prefer deleting only events your app created (filter by your extended property).
Output
Report: integration path, scopes or credentials used, how times and zones are stored, sync state stored per calendar, webhook channel renewal plan, and verification results.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Calendar Integrations"
short_description: "Events at the right time, in every calendar app"
default_prompt: "$calendar-integrations Add Google Calendar sync so bookings show up on the host's calendar" scripts/lint_ics.py
#!/usr/bin/env python3
"""Lint an iCalendar (.ics) file against the RFC 5545 rules that most often break imports.
Usage: python3 lint_ics.py <file.ics> [more.ics ...]
Exit code 0 when no errors were found, 1 otherwise. Warnings do not fail the run.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REQUIRED_EVENT = ("UID", "DTSTAMP", "DTSTART")
def unfold(raw: str) -> list[str]:
"""Join folded continuation lines (CRLF followed by a space or tab)."""
lines: list[str] = []
for line in re.split(r"\r\n|\n|\r", raw):
if line[:1] in (" ", "\t") and lines:
lines[-1] += line[1:]
elif line:
lines.append(line)
return lines
def prop_name(line: str) -> str:
return re.split(r"[;:]", line, maxsplit=1)[0].upper()
def params(line: str) -> dict[str, str]:
head = line.split(":", 1)[0]
out = {}
for part in head.split(";")[1:]:
if "=" in part:
k, v = part.split("=", 1)
out[k.upper()] = v.strip('"')
return out
def value(line: str) -> str:
return line.split(":", 1)[1] if ":" in line else ""
def lint(path: Path) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
data = path.read_bytes()
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return [f"{path}: not valid UTF-8"], []
physical = data.split(b"\n")
bare_lf = sum(1 for p in physical[:-1] if not p.endswith(b"\r"))
if bare_lf:
errors.append(f"{path}: {bare_lf} line(s) end with LF instead of CRLF")
for n, p in enumerate(physical, 1):
width = len(p.rstrip(b"\r"))
if width > 75:
warnings.append(f"{path}:{n}: line is {width} octets; fold at 75")
break
lines = unfold(text)
if not lines or lines[0].upper() != "BEGIN:VCALENDAR" or lines[-1].upper() != "END:VCALENDAR":
errors.append(f"{path}: must start with BEGIN:VCALENDAR and end with END:VCALENDAR")
names = [prop_name(l) for l in lines]
if "VERSION" not in names:
errors.append(f"{path}: VCALENDAR is missing VERSION:2.0")
if "PRODID" not in names:
errors.append(f"{path}: VCALENDAR is missing PRODID")
tz_defined = {value(l) for l in lines if prop_name(l) == "TZID"}
stack: list[str] = []
event: dict[str, str] = {}
event_lines: list[str] = []
uids: dict[str, int] = {}
count = 0
for line in lines:
name = prop_name(line)
if name == "BEGIN":
stack.append(value(line).upper())
if stack[-1] == "VEVENT":
event, event_lines = {}, []
continue
if name == "END":
comp = value(line).upper()
if not stack or stack[-1] != comp:
errors.append(f"{path}: END:{comp} does not match BEGIN:{stack[-1] if stack else '-'}")
else:
stack.pop()
if comp == "VEVENT":
count += 1
label = f"{path}: VEVENT #{count} ({event.get('SUMMARY', 'no summary')})"
for req in REQUIRED_EVENT:
if req not in event:
errors.append(f"{label} is missing {req}")
if "DTEND" in event and "DURATION" in event:
errors.append(f"{label} has both DTEND and DURATION")
uid = event.get("UID")
if uid:
uids[uid] = uids.get(uid, 0) + 1
start = event.get("DTSTART", "")
end = event.get("DTEND", "")
if start and end and len(start) == len(end) and end <= start:
errors.append(f"{label} DTEND {end} is not after DTSTART {start} (all-day end is exclusive)")
for l in event_lines:
tzid = params(l).get("TZID")
if tzid and tzid not in tz_defined:
warnings.append(f"{label} uses TZID={tzid} with no VTIMEZONE; some clients will float or misplace it")
break
continue
if stack and stack[-1] == "VEVENT":
event.setdefault(name, value(line))
event_lines.append(line)
if stack:
errors.append(f"{path}: unclosed components: {', '.join(stack)}")
for uid, n in uids.items():
if n > 1 and "RECURRENCE-ID" not in names:
errors.append(f"{path}: UID {uid} appears {n} times without RECURRENCE-ID overrides")
if count == 0:
warnings.append(f"{path}: no VEVENT components found")
return errors, warnings
def main(argv: list[str]) -> int:
if not argv:
print(__doc__.strip())
return 2
all_errors = 0
for arg in argv:
path = Path(arg)
if not path.is_file():
print(f"error: {arg} not found")
all_errors += 1
continue
errors, warnings = lint(path)
for w in warnings:
print(f"warn: {w}")
for e in errors:
print(f"error: {e}")
all_errors += len(errors)
if not errors:
print(f"ok: {path}")
return 1 if all_errors else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.