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.
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
UNTILand 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 oncreateandupdatewhen the target is a series master. Each entry must start with one ofRRULE:,EXDATE:,RDATE:,EXRULE:. Typical value:["RRULE:FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T235959Z"]. Passing[]onupdatestrips 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 onupdateanddelete.
Resolution rule:
- If the caller passes
scopeexplicitly, use it. - Otherwise fetch the event referenced by
event_idonce. If it hasrecurringEventId, defaultscope=instance. Else if it hasrecurrence, defaultscope=series. Else (plain single event),scopeis ignored.
Validation:
scope=instanceon an event with norecurringEventId→ errorevent_id is a series master; pass an instance id or use scope=series.scope=instancewithrecurrencein the same call → errorrecurrence 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". Defaultoccurrences. occurrences— current behaviour. Output lines for occurrences whoserecurringEventIdis set are suffixed with· recurring (series=<id>)so the agent can pivot to a series-scoped edit.series— setssingleEvents=false. Only master events come back. Each output line includes the RRULE joined fromevent.recurrence.
New action list_instances:
- Required:
event_id(a series master id). - Optional:
time_min,time_max,max_results(default 25, capped 250, same aslist). - Calls
GET /calendars/{calId}/events/{event_id}/instanceswith 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.recurrencein the body replaces the master's rule;[]strips it.scope=instance→PATCH /events/{event_id}whereevent_idis the instance id.recurrenceis 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 whenrecurrenceis 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_instanceswithoutevent_id→list_instances requires event_id (the series master id).update/deletewithscope=instanceon a non-instance →event_id is a series master; pass an instance id or use scope=series.updatewithrecurrenceandscope=instance→recurrence can only be set on the series master (scope=series).recurrenceentries not starting withRRULE:,EXDATE:,RDATE:, orEXRULE:→recurrence entries must be RFC 5545 lines (RRULE:..., EXDATE:..., RDATE:..., EXRULE:...).- Google API failures continue to surface through
ensureOkunchanged.
Types
CalendarEvent gains two optional fields matching Google's payload:
recurringEventId?: stringrecurrence?: 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:
createwithrecurrenceincludes it in the request body.updatewith default scope on an id whose fetched event hasrecurringEventIdPATCHes as an instance.updatewith default scope on an id whose fetched event hasrecurrencePATCHes as a series.update scope=instancerejects when the fetched event has norecurringEventId.update scope=instancerejects arecurrenceparam.delete scope=instanceDELETEs the instance id and returns the cancelled-occurrence message.delete scope=seriesDELETEs the master and returns the series message.list list_mode=seriessetssingleEvents=falseand output lines includeRRULE=….listoutput marks occurrences whoserecurringEventIdis set with the recurring suffix.list_instancesGETs the instances endpoint withtimeMin/timeMax/maxResults.- Invalid
recurrenceentry (missingRRULE: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.