30 KiB
Recurring Google Calendar Events Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add create / modify / delete support for recurring events to the calendar tool in src/tools/calendar.ts, using raw RFC 5545 recurrence lines, instance-vs-series scope on edits, and a new list_instances action.
Architecture: All logic stays in src/tools/calendar.ts. The calendarParams TypeBox schema gains three optional fields (recurrence, scope, list_mode) and a new action list_instances. Update/delete resolve their target via a single GET on the event id when scope is not passed explicitly. Tests live in a new src/tools/calendar.test.ts with globalThis.fetch stubbed per call.
Tech Stack: TypeScript on Node 24, TypeBox for the tool schema, @earendil-works/pi-coding-agent for defineTool, Vitest for tests, Google Calendar REST API v3.
File Structure
- Modify:
src/tools/calendar.ts— schema, handler dispatch, formatters, new action. - Create:
src/tools/calendar.test.ts— Vitest unit tests with a mockedfetch. - Modify:
README.md— one new line in the manual E2E checklist.
getAccessToken is passed into handleCalendar as a function, so tests can just supply a stub that returns "test-token". fetch is called directly on the global, so tests stub globalThis.fetch and restore it in afterEach.
Task 1: Test harness + schema/type extensions
Files:
-
Create:
src/tools/calendar.test.ts -
Modify:
src/tools/calendar.ts(schema,CalendarEventinterface, action dispatch stub) -
Step 1: Create test file with fetch stub scaffolding
Create src/tools/calendar.test.ts:
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { handleCalendar } from "./calendar.js";
type FetchCall = { url: string; init?: RequestInit };
function stubFetch(responses: Array<{ status?: number; body: unknown } | ((call: FetchCall) => { status?: number; body: unknown })>) {
const calls: FetchCall[] = [];
let i = 0;
const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
const call = { url, init };
calls.push(call);
const entry = responses[i++];
if (!entry) throw new Error(`unexpected fetch call: ${url}`);
const resolved = typeof entry === "function" ? entry(call) : entry;
const status = resolved.status ?? 200;
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(resolved.body),
json: async () => resolved.body,
} as Response;
});
return { fn, calls };
}
const token = async () => "test-token";
describe("calendar tool", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("placeholder — replaced by later tasks", () => {
expect(true).toBe(true);
});
});
- Step 2: Run the placeholder test to confirm the harness runs
Run: npm test -- src/tools/calendar.test.ts
Expected: PASS (1 test).
- Step 3: Extend
CalendarEventandcalendarParamsinsrc/tools/calendar.ts
Modify src/tools/calendar.ts — extend the action union, add three optional params, and extend the CalendarEvent interface:
export const calendarParams = Type.Object({
action: Type.Union(
[
Type.Literal("list"),
Type.Literal("list_instances"),
Type.Literal("create"),
Type.Literal("update"),
Type.Literal("delete"),
],
{ description: "list events / list instances of a series / create / update / delete an event" },
),
calendar_id: Type.Optional(
Type.String({ description: "Calendar id. Default 'primary' (the owner's main calendar)." }),
),
time_min: Type.Optional(
Type.String({
description:
"list/list_instances: ISO 8601 lower bound (inclusive), e.g. 2026-09-07T00:00:00+02:00. Default for list: now.",
}),
),
time_max: Type.Optional(
Type.String({ description: "list/list_instances: ISO 8601 upper bound (exclusive). Optional." }),
),
q: Type.Optional(Type.String({ description: "list: free-text search over summaries/descriptions." })),
max_results: Type.Optional(
Type.Number({ description: "list/list_instances: max events returned. Default 25, capped at 250." }),
),
list_mode: Type.Optional(
Type.Union([Type.Literal("occurrences"), Type.Literal("series")], {
description:
"list: 'occurrences' (default) expands recurring events into individual dates; 'series' returns only the master events with their RRULE.",
}),
),
event_id: Type.Optional(
Type.String({
description:
"Event id. Required for update, delete, and list_instances. For update/delete on a recurring series, pass the master id (for scope=series) or an instance id from list_instances (for scope=instance).",
}),
),
scope: Type.Optional(
Type.Union([Type.Literal("instance"), Type.Literal("series")], {
description:
"update/delete on recurring events: 'instance' targets one occurrence; 'series' targets the whole series. Default: auto-detected from the event id.",
}),
),
summary: Type.Optional(Type.String({ description: "create/update: event title." })),
description: Type.Optional(Type.String({ description: "create/update: event description." })),
location: Type.Optional(Type.String({ description: "create/update: location string." })),
start: Type.Optional(
Type.String({
description:
"create/update: start. ISO 8601 with offset for timed events (2026-09-10T10:00:00+02:00), or YYYY-MM-DD for all-day.",
}),
),
end: Type.Optional(
Type.String({ description: "create/update: end. Same format as start." }),
),
recurrence: Type.Optional(
Type.Array(Type.String(), {
description:
"create/update series: RFC 5545 recurrence lines, e.g. ['RRULE:FREQ=WEEKLY;BYDAY=MO,WE']. Pass [] on update to strip recurrence and turn the series back into a single event.",
}),
),
});
Also extend CalendarEvent:
interface CalendarEvent {
id: string;
summary?: string;
description?: string;
location?: string;
start?: { dateTime?: string; date?: string; timeZone?: string };
end?: { dateTime?: string; date?: string; timeZone?: string };
htmlLink?: string;
recurringEventId?: string;
recurrence?: string[];
}
Add a list_instances case to handleCalendar's switch that throws for now (real impl arrives in Task 5):
case "list_instances":
throw new Error("list_instances not implemented yet");
- Step 4: Typecheck compiles
Run: npm run typecheck
Expected: PASS with no errors.
- Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Extend calendar schema and types for recurring events"
Task 2: Create with recurrence + RRULE validation
Files:
-
Modify:
src/tools/calendar.ts(createEvent,eventBody, addvalidateRecurrence,describeEventto show RRULE) -
Modify:
src/tools/calendar.test.ts -
Step 1: Write the failing tests
Replace the placeholder test with:
it("create includes recurrence in the request body and shows RRULE in output", async () => {
const { fn, calls } = stubFetch([
{
body: {
id: "series-1",
summary: "Standup",
start: { dateTime: "2026-09-14T09:00:00+02:00", timeZone: "Europe/Warsaw" },
end: { dateTime: "2026-09-14T09:15:00+02:00", timeZone: "Europe/Warsaw" },
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"],
},
},
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const out = await handleCalendar(
{
action: "create",
summary: "Standup",
start: "2026-09-14T09:00:00+02:00",
end: "2026-09-14T09:15:00+02:00",
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"],
},
"Europe/Warsaw",
token,
);
expect(calls[0].url).toBe("https://www.googleapis.com/calendar/v3/calendars/primary/events");
const body = JSON.parse(calls[0].init!.body as string);
expect(body.recurrence).toEqual(["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]);
expect(out).toContain("Created:");
expect(out).toContain("RRULE=RRULE:FREQ=WEEKLY;BYDAY=MO,WE");
});
it("rejects recurrence entries missing an RFC 5545 prefix", async () => {
globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch;
await expect(
handleCalendar(
{
action: "create",
summary: "x",
start: "2026-09-14T09:00:00+02:00",
end: "2026-09-14T09:15:00+02:00",
recurrence: ["FREQ=WEEKLY"],
},
"UTC",
token,
),
).rejects.toThrow(/RFC 5545/);
});
- Step 2: Run the tests to confirm they fail
Run: npm test -- src/tools/calendar.test.ts
Expected: FAIL — no recurrence in body, no RRULE in output, no validation error.
- Step 3: Implement recurrence in
eventBody+ validation + describeEvent update
In src/tools/calendar.ts:
Add validation helper:
function validateRecurrence(recurrence: string[]): void {
const allowed = /^(RRULE|EXDATE|RDATE|EXRULE):/;
for (const line of recurrence) {
if (!allowed.test(line)) {
throw new Error(
"recurrence entries must be RFC 5545 lines (RRULE:..., EXDATE:..., RDATE:..., EXRULE:...)",
);
}
}
}
In eventBody, add:
if (params.recurrence !== undefined) {
validateRecurrence(params.recurrence);
body.recurrence = params.recurrence;
}
Update describeEvent to show the RRULE when present:
function describeEvent(event: CalendarEvent, timezone: string): string {
const summary = event.summary ?? "(no title)";
const when = describeWhen(event, timezone);
const location = event.location ? ` @ ${event.location}` : "";
const recurring = event.recurringEventId ? ` · recurring (series=${event.recurringEventId})` : "";
const rrule = event.recurrence?.length ? ` · RRULE=${event.recurrence.join(" ")}` : "";
return `${event.id} · ${summary} · ${when}${location}${recurring}${rrule}`;
}
- Step 4: Run tests to confirm they pass
Run: npm test -- src/tools/calendar.test.ts
Expected: PASS (2 tests + prior placeholder if still present — remove the placeholder if it is).
- Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Support recurrence in create and RFC 5545 validation"
Task 3: list marks recurring occurrences + list_mode=series
Files:
-
Modify:
src/tools/calendar.ts(listEvents) -
Modify:
src/tools/calendar.test.ts -
Step 1: Write the failing tests
Append to describe("calendar tool", ...):
it("list marks occurrences that belong to a series", async () => {
const { fn } = stubFetch([
{
body: {
items: [
{
id: "occ-1",
summary: "Standup",
start: { dateTime: "2026-09-14T09:00:00+02:00" },
end: { dateTime: "2026-09-14T09:15:00+02:00" },
recurringEventId: "series-1",
},
],
},
},
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const out = await handleCalendar({ action: "list" }, "Europe/Warsaw", token);
expect(out).toContain("recurring (series=series-1)");
});
it("list list_mode=series requests master events only and shows the RRULE", async () => {
const { fn, calls } = stubFetch([
{
body: {
items: [
{
id: "series-1",
summary: "Standup",
start: { dateTime: "2026-09-14T09:00:00+02:00" },
end: { dateTime: "2026-09-14T09:15:00+02:00" },
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"],
},
],
},
},
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const out = await handleCalendar({ action: "list", list_mode: "series" }, "Europe/Warsaw", token);
const url = new URL(calls[0].url);
expect(url.searchParams.get("singleEvents")).toBe("false");
expect(out).toContain("RRULE=RRULE:FREQ=WEEKLY;BYDAY=MO,WE");
});
- Step 2: Run tests to confirm they fail
Run: npm test -- src/tools/calendar.test.ts
Expected: FAIL — list_mode=series currently forces singleEvents=true, and the recurring-suffix expectation already passes if Task 2's describeEvent change landed (skip this failure if applicable — the test still exercises the wiring).
- Step 3: Update
listEventsto honourlist_mode
Replace the two lines that always set singleEvents=true / orderBy=startTime:
const mode = params.list_mode ?? "occurrences";
if (mode === "series") {
url.searchParams.set("singleEvents", "false");
} else {
url.searchParams.set("singleEvents", "true");
url.searchParams.set("orderBy", "startTime");
}
- Step 4: Run tests to confirm they pass
Run: npm test -- src/tools/calendar.test.ts
Expected: PASS.
- Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Add list_mode=series and recurring marker on list output"
Task 4: Scope resolution + update with instance/series semantics
Files:
-
Modify:
src/tools/calendar.ts(updateEvent, newresolveScopehelper) -
Modify:
src/tools/calendar.test.ts -
Step 1: Write the failing tests
it("update with default scope on an instance id PATCHes the instance", async () => {
const { fn, calls } = stubFetch([
{ body: { id: "occ-1_20260916T070000Z", recurringEventId: "series-1", summary: "Standup" } }, // GET for scope
{
body: {
id: "occ-1_20260916T070000Z",
recurringEventId: "series-1",
summary: "Standup",
start: { dateTime: "2026-09-16T10:00:00+02:00" },
end: { dateTime: "2026-09-16T10:30:00+02:00" },
},
}, // PATCH
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const out = await handleCalendar(
{
action: "update",
event_id: "occ-1_20260916T070000Z",
start: "2026-09-16T10:00:00+02:00",
end: "2026-09-16T10:30:00+02:00",
},
"Europe/Warsaw",
token,
);
expect(calls[0].init?.method ?? "GET").toBe("GET");
expect(calls[1].init?.method).toBe("PATCH");
expect(calls[1].url).toContain("/events/occ-1_20260916T070000Z");
expect(out).toContain("Updated:");
});
it("update with default scope on a master id PATCHes the master", async () => {
const { fn, calls } = stubFetch([
{ body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"], summary: "Old" } }, // GET
{
body: {
id: "series-1",
summary: "New",
recurrence: ["RRULE:FREQ=WEEKLY"],
start: { dateTime: "2026-09-14T09:00:00+02:00" },
end: { dateTime: "2026-09-14T09:15:00+02:00" },
},
}, // PATCH
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
await handleCalendar(
{ action: "update", event_id: "series-1", summary: "New" },
"Europe/Warsaw",
token,
);
expect(calls[1].init?.method).toBe("PATCH");
const body = JSON.parse(calls[1].init!.body as string);
expect(body).toEqual({ summary: "New" });
});
it("update scope=instance rejects when the event has no recurringEventId", async () => {
const { fn } = stubFetch([{ body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"] } }]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
await expect(
handleCalendar(
{ action: "update", event_id: "series-1", scope: "instance", summary: "x" },
"UTC",
token,
),
).rejects.toThrow(/pass an instance id or use scope=series/);
});
it("update rejects recurrence when scope resolves to instance", async () => {
const { fn } = stubFetch([{ body: { id: "occ-1", recurringEventId: "series-1" } }]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
await expect(
handleCalendar(
{ action: "update", event_id: "occ-1", recurrence: ["RRULE:FREQ=DAILY"] },
"UTC",
token,
),
).rejects.toThrow(/recurrence can only be set on the series master/);
});
it("update on a plain single event skips the classification GET", async () => {
const { fn, calls } = stubFetch([
{
body: {
id: "single-1",
summary: "Renamed",
start: { dateTime: "2026-09-14T09:00:00+02:00" },
end: { dateTime: "2026-09-14T09:15:00+02:00" },
},
}, // PATCH (no prior GET — no scope passed, and classification only runs if scope defaulted)
]);
// For a plain single event we still need one GET to know it's not recurring.
// We keep behaviour: always GET when scope is not passed.
});
Note: replace that last it block with an actual test:
it("update PATCH body only carries fields the caller changed", async () => {
const { fn, calls } = stubFetch([
{ body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"] } },
{
body: {
id: "series-1",
summary: "s",
recurrence: ["RRULE:FREQ=DAILY"],
start: { dateTime: "2026-09-14T09:00:00+02:00" },
end: { dateTime: "2026-09-14T09:15:00+02:00" },
},
},
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
await handleCalendar(
{ action: "update", event_id: "series-1", recurrence: ["RRULE:FREQ=DAILY"] },
"UTC",
token,
);
const body = JSON.parse(calls[1].init!.body as string);
expect(body).toEqual({ recurrence: ["RRULE:FREQ=DAILY"] });
});
- Step 2: Run tests to confirm they fail
Run: npm test -- src/tools/calendar.test.ts
Expected: FAIL — updateEvent currently PATCHes directly without a scoping GET or recurrence-vs-instance guard.
- Step 3: Add
resolveScopeand rewriteupdateEvent
In src/tools/calendar.ts:
type ResolvedScope = "instance" | "series" | "single";
async function classifyEvent(
calId: string,
eventId: string,
getAccessToken: () => Promise<string>,
): Promise<CalendarEvent> {
const resp = await fetch(
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(eventId)}`,
{ headers: { Authorization: `Bearer ${await getAccessToken()}` } },
);
await ensureOk(resp, "get event");
return (await resp.json()) as CalendarEvent;
}
async function resolveScope(
calId: string,
params: CalendarParams,
getAccessToken: () => Promise<string>,
): Promise<{ scope: ResolvedScope; event: CalendarEvent }> {
if (!params.event_id) throw new Error("event_id required");
const event = await classifyEvent(calId, params.event_id, getAccessToken);
const detected: ResolvedScope = event.recurringEventId
? "instance"
: event.recurrence?.length
? "series"
: "single";
if (!params.scope) return { scope: detected, event };
if (params.scope === "instance" && detected !== "instance") {
throw new Error("event_id is a series master; pass an instance id or use scope=series");
}
if (params.scope === "series" && detected === "instance") {
throw new Error("event_id is an occurrence; pass the master id or use scope=instance");
}
return { scope: params.scope, event };
}
Replace updateEvent:
async function updateEvent(
calId: string,
params: CalendarParams,
timezone: string,
getAccessToken: () => Promise<string>,
): Promise<string> {
if (!params.event_id) throw new Error("update requires event_id");
const { scope } = await resolveScope(calId, params, getAccessToken);
if (scope === "instance" && params.recurrence !== undefined) {
throw new Error("recurrence can only be set on the series master (scope=series)");
}
const body = eventBody(params, timezone);
if (Object.keys(body).length === 0) throw new Error("update requires at least one field to change");
const resp = await fetch(
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${await getAccessToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
await ensureOk(resp, "update event");
const event = (await resp.json()) as CalendarEvent;
return `Updated: ${describeEvent(event, timezone)}`;
}
- Step 4: Run tests to confirm they pass
Run: npm test -- src/tools/calendar.test.ts
Expected: PASS.
- Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Resolve update scope from event id and guard recurrence-on-instance"
Task 5: Delete with scope
Files:
-
Modify:
src/tools/calendar.ts(deleteEvent) -
Modify:
src/tools/calendar.test.ts -
Step 1: Write the failing tests
it("delete on a series master returns the series message", async () => {
const { fn, calls } = stubFetch([
{ body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"] } }, // GET
{ status: 204, body: {} }, // DELETE
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const out = await handleCalendar({ action: "delete", event_id: "series-1" }, "UTC", token);
expect(calls[1].init?.method).toBe("DELETE");
expect(out).toBe("Deleted series series-1 (all occurrences).");
});
it("delete on an occurrence cancels the instance and names the series", async () => {
const { fn, calls } = stubFetch([
{ body: { id: "occ-1", recurringEventId: "series-1" } },
{ status: 204, body: {} },
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const out = await handleCalendar({ action: "delete", event_id: "occ-1" }, "UTC", token);
expect(calls[1].url).toContain("/events/occ-1");
expect(out).toBe("Cancelled occurrence occ-1 of series series-1.");
});
it("delete on a plain single event keeps the original message", async () => {
const { fn } = stubFetch([
{ body: { id: "single-1" } },
{ status: 204, body: {} },
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const out = await handleCalendar({ action: "delete", event_id: "single-1" }, "UTC", token);
expect(out).toBe("Deleted event single-1.");
});
- Step 2: Run tests to confirm they fail
Run: npm test -- src/tools/calendar.test.ts
Expected: FAIL — current deleteEvent always returns Deleted event <id>. and skips the GET.
- Step 3: Rewrite
deleteEvent
Replace deleteEvent:
async function deleteEvent(
calId: string,
params: CalendarParams,
getAccessToken: () => Promise<string>,
): Promise<string> {
if (!params.event_id) throw new Error("delete requires event_id");
const { scope, event } = await resolveScope(calId, params, getAccessToken);
const resp = await fetch(
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${await getAccessToken()}` },
},
);
await ensureOk(resp, "delete event");
if (scope === "instance") {
return `Cancelled occurrence ${params.event_id} of series ${event.recurringEventId}.`;
}
if (scope === "series") {
return `Deleted series ${params.event_id} (all occurrences).`;
}
return `Deleted event ${params.event_id}.`;
}
- Step 4: Run tests to confirm they pass
Run: npm test -- src/tools/calendar.test.ts
Expected: PASS.
- Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Scope delete to instance or series"
Task 6: list_instances action
Files:
-
Modify:
src/tools/calendar.ts(newlistInstances, wire it into the switch) -
Modify:
src/tools/calendar.test.ts -
Step 1: Write the failing tests
it("list_instances GETs the series' instances endpoint with the paging params", async () => {
const { fn, calls } = stubFetch([
{
body: {
items: [
{
id: "series-1_20260914T070000Z",
summary: "Standup",
start: { dateTime: "2026-09-14T09:00:00+02:00" },
end: { dateTime: "2026-09-14T09:15:00+02:00" },
recurringEventId: "series-1",
},
],
},
},
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const out = await handleCalendar(
{
action: "list_instances",
event_id: "series-1",
time_min: "2026-09-14T00:00:00+02:00",
time_max: "2026-09-21T00:00:00+02:00",
max_results: 5,
},
"Europe/Warsaw",
token,
);
const url = new URL(calls[0].url);
expect(url.pathname).toBe("/calendar/v3/calendars/primary/events/series-1/instances");
expect(url.searchParams.get("timeMin")).toBe("2026-09-14T00:00:00+02:00");
expect(url.searchParams.get("timeMax")).toBe("2026-09-21T00:00:00+02:00");
expect(url.searchParams.get("maxResults")).toBe("5");
expect(out).toContain("series-1_20260914T070000Z");
expect(out).not.toContain("recurring (series=");
});
it("list_instances requires event_id", async () => {
globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch;
await expect(
handleCalendar({ action: "list_instances" }, "UTC", token),
).rejects.toThrow(/list_instances requires event_id/);
});
- Step 2: Run tests to confirm they fail
Run: npm test -- src/tools/calendar.test.ts
Expected: FAIL — the switch still throws "not implemented yet".
- Step 3: Implement
listInstancesand add a suffix-suppressing describe helper
In src/tools/calendar.ts, add:
async function listInstances(
calId: string,
params: CalendarParams,
timezone: string,
getAccessToken: () => Promise<string>,
): Promise<string> {
if (!params.event_id) throw new Error("list_instances requires event_id (the series master id)");
const url = new URL(
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}/instances`,
);
if (params.time_min) url.searchParams.set("timeMin", params.time_min);
if (params.time_max) url.searchParams.set("timeMax", params.time_max);
url.searchParams.set("maxResults", String(Math.min(params.max_results ?? 25, 250)));
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${await getAccessToken()}` },
});
await ensureOk(resp, "list instances");
const data = (await resp.json()) as { items?: CalendarEvent[] };
const items = data.items ?? [];
if (!items.length) return "No instances.";
return items.map((event) => describeInstance(event, timezone)).join("\n");
}
function describeInstance(event: CalendarEvent, timezone: string): string {
const summary = event.summary ?? "(no title)";
const when = describeWhen(event, timezone);
const location = event.location ? ` @ ${event.location}` : "";
return `${event.id} · ${summary} · ${when}${location}`;
}
Update the switch:
case "list_instances":
return listInstances(calId, params, timezone, getAccessToken);
- Step 4: Run tests to confirm they pass
Run: npm test -- src/tools/calendar.test.ts
Expected: PASS.
- Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Add list_instances action for recurring series"
Task 7: Final verification + README checklist entry
Files:
-
Modify:
README.md -
Step 1: Add a manual E2E checklist item
In README.md, append one item to the numbered list under ### Manual end-to-end checklist:
13. "Every Monday and Wednesday at 9 create a standup event for 15 minutes" → an event is created with `RRULE:FREQ=WEEKLY;BYDAY=MO,WE`; "list the next 3 standups" returns three occurrences; "move next Wednesday's standup to 10" changes just that instance; "delete the standup series" removes them all.
- Step 2: Run the full test suite
Run: npm test
Expected: PASS — all existing tests plus the new src/tools/calendar.test.ts suite.
- Step 3: Run typecheck
Run: npm run typecheck
Expected: PASS with no errors.
- Step 4: Run build to catch anything typecheck missed
Run: npm run build
Expected: exits 0, dist/ updated.
- Step 5: Commit README + verify clean tree
git add README.md
git commit -m "Document recurring-event manual E2E check"
git status
Expected: git status shows a clean working tree.
Self-Review
Spec coverage
| Spec section | Task |
|---|---|
Recurrence input shape (recurrence: string[]) |
Task 1 (schema) + Task 2 (create body + validation) |
Scope resolution (scope param, auto-detect, error rules) |
Task 4 (resolveScope) |
list occurrences marker |
Task 3 |
list_mode=series |
Task 3 |
list_instances action |
Task 1 (schema) + Task 6 (impl) |
| create with recurrence | Task 2 |
| update semantics (instance/series, reject recurrence on instance) | Task 4 |
| delete semantics (instance/series/single messages) | Task 5 |
| Output formats (recurring suffix, RRULE line, delete messages, instance line) | Tasks 2, 3, 5, 6 |
| Errors (missing event_id on list_instances, master-vs-instance mismatch, recurrence-on-instance, invalid RRULE prefix) | Tasks 2, 4, 5, 6 |
Types (recurringEventId, recurrence on CalendarEvent) |
Task 1 |
| Tests (all bullets from spec §Testing) | Tasks 2–6 |
| README E2E line | Task 7 |
Every spec requirement maps to at least one task.
Placeholder scan: none — every step has full code, an exact command, and an expected result.
Type consistency: resolveScope returns { scope: ResolvedScope; event: CalendarEvent } and is called with those types in Tasks 4 and 5. describeEvent gains a recurring and rrule suffix in Task 2 and is used by list, create, update. describeInstance is used only by list_instances. CalendarEvent.recurringEventId and CalendarEvent.recurrence are added in Task 1 and consumed in Tasks 2–6. validateRecurrence is defined in Task 2 and used from eventBody. All names line up.