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.

  • Integrations
  • $calendar-integrations
  • 197 lines
  • 3 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- calendar-integrations

Installs to ~/.agents/skills/calendar-integrations/ for every project on this machine.

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

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.

~/.agents/skills/calendar-integrations/SKILL.md

--- name: calendar-integrations description: Integrates apps with calendars - Google Calendar API v3 (events, freeBusy, incremental sync with syncToken, push channels), CalDAV servers such as… license: MIT ---

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

  1. Read AGENTS.md; find existing code with rg -n "googleapis|calendar|caldav|ical|icalendar|rrule|ics" --glob '!node_modules'.
  2. 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.
  3. Check how times are stored today (timestamp with zone, naive local, strings) before adding fields.
  4. Outlook / Microsoft 365 calendars go through Microsoft Graph; hand off to $microsoft-graph.

Pick the integration path

NeedPath
Users connect Google accountsGoogle Calendar API v3 with OAuth (per-user tokens)
Workspace-wide access to company calendarsService account with domain-wide delegation (admin must grant)
iCloud, Fastmail, Nextcloud, self-hostedCalDAV (caldav for Python, tsdav for Node)
“Add to calendar” button or email inviteGenerate .ics (icalendar for Python, ical-generator for Node)
Subscribable feed of app eventsPublic or secret-token .ics URL served with text/calendar
Expand RRULEs from any sourcepython-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:20261102 and DTEND;VALUE=DATE:20261103; Google uses start.date and end.date the 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 with extendedProperties.private so you can find your own events later (privateExtendedProperty=bookingId=... filter on events.list).
  • Use a deterministic id (base32hex, 5-1024 chars) or requestId to make retries idempotent; a duplicate id returns 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
  • syncToken cannot be combined with timeMin, timeMax, q or most filters; do the first sync with them, later syncs with the token only.
  • Recurring series arrive as one master plus exceptions (recurringEventId, originalStartTime). Use singleEvents=True only 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 with If-Match to avoid overwriting a concurrent edit (412 means re-fetch and merge). Use the collection getctag / sync-token to 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 UID with a higher SEQUENCE; cancellations send METHOD:CANCEL and STATUS:CANCELLED with the same UID.
  • 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 .ics passes lint_ics.py and 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

  • dateTime without timeZone or an offset is rejected or misread; always send one.
  • Google returns cancelled instances of recurring events as exceptions; treat status: cancelled as a delete even without other fields.
  • Free/busy for other users only works if they share at least free/busy visibility.
  • Outlook ignores .ics updates whose SEQUENCE did 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.yamlgenerated from this page

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.py151 lines, 5.1 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem