Files
VolcanicSecretary/docs/superpowers/specs/2026-09-08-recurring-calendar-events-design.md
EugeneTes 30695cb121 Add design for recurring Google Calendar events
Extends the calendar tool to create, modify, and delete recurring events
via raw RFC 5545 recurrence lines, with instance vs. series scope on
update/delete and a list_instances action to fetch a series' occurrences.
2026-09-08 07:56:24 +00:00

7.8 KiB

Recurring Google Calendar events

Goal

Extend the calendar tool in src/tools/calendar.ts so the agent can create, modify, and delete recurring events in the owner's Google Calendar. Today the tool sets singleEvents=true on list and provides no way to write a recurrence field, so recurring events can only be observed (as expanded occurrences) and never authored or altered.

Non-goals

  • "This and following" edits. Google has no native endpoint for splitting a series; emulating it (patching the master's RRULE with an UNTIL and creating a new series) has enough edge cases that we defer it. If it turns out to matter in practice we add it later.
  • Reminders, attendees, transparency, conferencing, colour, and other event fields the current tool doesn't already touch.
  • Full RFC 5545 parsing/validation. We accept RRULE strings verbatim and let the Google API be the authority on their correctness.

Recurrence input shape

The agent supplies recurrence as raw RFC 5545 lines, matching Google Calendar's event.recurrence field 1:1.

New optional parameter on calendarParams:

  • recurrence: string[] — used on create and update when the target is a series master. Each entry must start with one of RRULE:, EXDATE:, RDATE:, EXRULE:. Typical value: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T235959Z"]. Passing [] on update strips recurrence and turns a series back into a single event.

Scope resolution for update / delete

Google Calendar distinguishes a series master (an event with recurrence) from an occurrence (an event whose recurringEventId points at a master). Edits to a master change every occurrence; edits to an occurrence change (or cancel) just that one date.

New optional parameter:

  • scope: "instance" | "series" — used on update and delete.

Resolution rule:

  1. If the caller passes scope explicitly, use it.
  2. Otherwise fetch the event referenced by event_id once. If it has recurringEventId, default scope=instance. Else if it has recurrence, default scope=series. Else (plain single event), scope is ignored.

Validation:

  • scope=instance on an event with no recurringEventId → error event_id is a series master; pass an instance id or use scope=series.
  • scope=instance with recurrence in the same call → error recurrence can only be set on the series master (scope=series).

For delete, scope=instance cancels one occurrence (Google records it as status: "cancelled"); scope=series deletes the master, removing every occurrence.

Listing

Existing list action keeps singleEvents=true as the default and continues to expand recurrences into occurrences. Additions:

  • New optional parameter list_mode: "occurrences" | "series". Default occurrences.
  • occurrences — current behaviour. Output lines for occurrences whose recurringEventId is set are suffixed with · recurring (series=<id>) so the agent can pivot to a series-scoped edit.
  • series — sets singleEvents=false. Only master events come back. Each output line includes the RRULE joined from event.recurrence.

New action list_instances:

  • Required: event_id (a series master id).
  • Optional: time_min, time_max, max_results (default 25, capped 250, same as list).
  • Calls GET /calendars/{calId}/events/{event_id}/instances with those params.
  • Output: one line per occurrence formatted like a normal occurrence line, without the recurring suffix.

The purpose of list_instances is to give the agent a way to obtain the instance id for a specific date so it can then act on that occurrence with scope=instance.

API surface summary

calendarParams.action gains "list_instances", joining list, create, update, delete.

New optional params (all typed and described so the agent picks them correctly):

  • recurrence: string[]
  • scope: "instance" | "series"
  • list_mode: "occurrences" | "series"

Every existing param keeps its current name and semantics.

Behaviour per action

create. Unchanged path, plus: if recurrence is present, include it in the POST body. start/end continue to define DTSTART/DTEND of the first occurrence.

update. Resolve scope as above.

  • scope=series → PATCH /events/{event_id} with the event body built from whatever fields the caller passed. recurrence in the body replaces the master's rule; [] strips it.
  • scope=instance → PATCH /events/{event_id} where event_id is the instance id. recurrence is rejected here.

delete. Resolve scope as above.

  • scope=series → DELETE /events/{event_id} on the master.
  • scope=instance → DELETE /events/{event_id} on the instance id.

list. Two modes, one action. occurrences (default) is the current call with singleEvents=true plus the recurring marker on output. series sets singleEvents=false and shows RRULE per line.

list_instances. GET /calendars/{calId}/events/{event_id}/instances.

Output format

Extending describeEvent and adding formatters. Formats:

  • Occurrence in list (occurrences): <id> · <summary> · <when>[ @ <loc>][ · recurring (series=<seriesId>)].
  • Series master in list (series) and in create/update responses when recurrence is set: <id> · <summary> · <first-start> · RRULE=<joined recurrence lines>[ @ <loc>]. <first-start> uses the master's DTSTART returned by Google.
  • list_instances: one occurrence line per instance, no recurring suffix.
  • Delete of an instance: Cancelled occurrence <instance_id> of series <seriesId>.
  • Delete of a series: Deleted series <event_id> (all occurrences).
  • Delete of a plain single event: unchanged (Deleted event <event_id>.).

Errors

  • list_instances without event_id → list_instances requires event_id (the series master id).
  • update/delete with scope=instance on a non-instance → event_id is a series master; pass an instance id or use scope=series.
  • update with recurrence and scope=instance → recurrence can only be set on the series master (scope=series).
  • recurrence entries not starting with RRULE:, EXDATE:, RDATE:, or EXRULE: → recurrence entries must be RFC 5545 lines (RRULE:..., EXDATE:..., RDATE:..., EXRULE:...).
  • Google API failures continue to surface through ensureOk unchanged.

Types

CalendarEvent gains two optional fields matching Google's payload:

  • recurringEventId?: string
  • recurrence?: string[]

Everything else in the existing interface stays as-is.

Testing

Unit tests in a new src/tools/calendar.test.ts with a mocked fetch:

  • create with recurrence includes it in the request body.
  • update with default scope on an id whose fetched event has recurringEventId PATCHes as an instance.
  • update with default scope on an id whose fetched event has recurrence PATCHes as a series.
  • update scope=instance rejects when the fetched event has no recurringEventId.
  • update scope=instance rejects a recurrence param.
  • delete scope=instance DELETEs the instance id and returns the cancelled-occurrence message.
  • delete scope=series DELETEs the master and returns the series message.
  • list list_mode=series sets singleEvents=false and output lines include RRULE=….
  • list output marks occurrences whose recurringEventId is set with the recurring suffix.
  • list_instances GETs the instances endpoint with timeMin/timeMax/maxResults.
  • Invalid recurrence entry (missing RRULE: prefix on any element) is rejected.

No new live/integration tests. README's manual E2E checklist gains one entry: create a weekly recurring event, list its next few instances, move one instance, delete the whole series.

Out of scope reminders

  • No "this and following" edits.
  • No support for editing attendees, reminders, timezone overrides, colour, or other fields the tool doesn't already touch.
  • No client-side RRULE parsing.