Skip to content

Commit 8bda182

Browse files
authored
feat(google-calendar): add RSVP operation to respond to invitations (#8019)
* feat(google-calendar): add RSVP operation to respond to invitations * fix(google-calendar): honor cancellation on RSVP write and tighten outputs * fix(google-calendar): verify the RSVP note was saved
1 parent bb263f8 commit 8bda182

11 files changed

Lines changed: 549 additions & 8 deletions

File tree

apps/docs/content/docs/integrations/google_calendar.mdx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,35 @@ Invite attendees to an existing Google Calendar event. Returns API-aligned field
311311
| `creator` | json | Event creator |
312312
| `organizer` | json | Event organizer |
313313

314+
### Google Calendar Respond to Invitation
315+
316+
RSVP to a Google Calendar event (accept, decline, or tentative) as the connected account. Only your own response changes; other guests are left untouched. Returns API-aligned fields only.
317+
318+
#### Input
319+
320+
| Parameter | Type | Required | Description |
321+
| --------- | ---- | -------- | ----------- |
322+
| `calendarId` | string | No | Google Calendar ID the invitation appears on \(e.g., primary or calendar@group.calendar.google.com\) |
323+
| `eventId` | string | Yes | Google Calendar event ID to respond to. Use a recurring-event instance ID \(as returned by List Events or Get Recurring Instances\) to respond to a single occurrence; the series ID responds to every occurrence. |
324+
| `responseStatus` | string | Yes | Your response: accepted, declined, or tentative |
325+
| `comment` | string | No | Optional note to include with your response |
326+
| `sendUpdates` | string | No | Who to notify about your response: all, externalOnly, or none |
327+
328+
#### Output
329+
330+
| Parameter | Type | Description |
331+
| --------- | ---- | ----------- |
332+
| `id` | string | Event ID |
333+
| `htmlLink` | string | Event link |
334+
| `status` | string | Event status |
335+
| `summary` | string | Event title |
336+
| `start` | json | Event start |
337+
| `end` | json | Event end |
338+
| `responseStatus` | string | Your confirmed response \(accepted, declined, or tentative\) |
339+
| `comment` | string | Your response comment |
340+
| `attendees` | json | Event attendees |
341+
| `organizer` | json | Event organizer |
342+
314343
### Google Calendar Free/Busy
315344

316345
Query free/busy information for one or more Google Calendars. Returns API-aligned fields only.

apps/sim/blocks/blocks/google_calendar.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
8989
{ text: 'Invite', field: 'attendees', core: true },
9090
{ text: 'to event', field: 'eventId', core: true },
9191
],
92+
respond: [
93+
{ text: 'Respond', field: 'responseStatus', core: true },
94+
{ text: 'to event', field: 'eventId', core: true },
95+
],
9296
freebusy: [
9397
{ text: 'Check free/busy for', field: 'calendarIds', core: true },
9498
{ text: ', starting', field: 'timeMin' },
@@ -138,6 +142,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
138142
{ label: 'List Calendars', id: 'list_calendars' },
139143
{ label: 'Quick Add (Natural Language)', id: 'quick_add' },
140144
{ label: 'Invite Attendees', id: 'invite' },
145+
{ label: 'Respond to Invitation (RSVP)', id: 'respond' },
141146
{ label: 'Check Free/Busy', id: 'freebusy' },
142147
{ label: 'Create Calendar', id: 'create_calendar' },
143148
{ label: 'Update Calendar', id: 'update_calendar' },
@@ -415,7 +420,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
415420
placeholder: 'Event ID',
416421
condition: {
417422
field: 'operation',
418-
value: ['get', 'update', 'delete', 'move', 'instances', 'invite'],
423+
value: ['get', 'update', 'delete', 'move', 'instances', 'invite', 'respond'],
419424
},
420425
required: true,
421426
},
@@ -618,6 +623,28 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
618623
],
619624
},
620625

626+
{
627+
id: 'responseStatus',
628+
title: 'Response',
629+
type: 'dropdown',
630+
condition: { field: 'operation', value: 'respond' },
631+
required: true,
632+
options: [
633+
{ label: 'Yes (accept)', id: 'accepted' },
634+
{ label: 'No (decline)', id: 'declined' },
635+
{ label: 'Maybe (tentative)', id: 'tentative' },
636+
],
637+
value: () => 'accepted',
638+
},
639+
{
640+
id: 'comment',
641+
title: 'Response Note',
642+
type: 'short-input',
643+
placeholder: 'Running 5 minutes late',
644+
condition: { field: 'operation', value: 'respond' },
645+
mode: 'advanced',
646+
},
647+
621648
{
622649
id: 'text',
623650
title: 'Natural Language Event',
@@ -820,7 +847,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
820847
type: 'dropdown',
821848
condition: {
822849
field: 'operation',
823-
value: ['create', 'update', 'delete', 'move', 'quick_add', 'invite'],
850+
value: ['create', 'update', 'delete', 'move', 'quick_add', 'invite', 'respond'],
824851
},
825852
options: [
826853
{ label: 'All attendees', id: 'all' },
@@ -842,6 +869,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
842869
'google_calendar_list_calendars',
843870
'google_calendar_quick_add',
844871
'google_calendar_invite',
872+
'google_calendar_respond',
845873
'google_calendar_freebusy',
846874
'google_calendar_create_calendar',
847875
'google_calendar_update_calendar',
@@ -874,6 +902,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
874902
return 'google_calendar_quick_add'
875903
case 'invite':
876904
return 'google_calendar_invite'
905+
case 'respond':
906+
return 'google_calendar_respond'
877907
case 'freebusy':
878908
return 'google_calendar_freebusy'
879909
case 'create_calendar':
@@ -950,7 +980,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
950980
}
951981

952982
if (
953-
['create', 'update', 'delete', 'move', 'quick_add', 'invite'].includes(operation) &&
983+
['create', 'update', 'delete', 'move', 'quick_add', 'invite', 'respond'].includes(
984+
operation
985+
) &&
954986
!processedParams.sendUpdates
955987
) {
956988
processedParams.sendUpdates = 'all'
@@ -1014,6 +1046,12 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
10141046

10151047
replaceExisting: { type: 'string', description: 'Replace existing attendees' },
10161048

1049+
responseStatus: {
1050+
type: 'string',
1051+
description: 'RSVP response (accepted, declined, or tentative)',
1052+
},
1053+
comment: { type: 'string', description: 'Note included with the RSVP response' },
1054+
10171055
sendUpdates: { type: 'string', description: 'Send email notifications' },
10181056
},
10191057
outputs: {
@@ -1046,6 +1084,7 @@ export const GoogleCalendarV2Block: BlockConfig<GoogleCalendarResponse> = {
10461084
'google_calendar_list_calendars_v2',
10471085
'google_calendar_quick_add_v2',
10481086
'google_calendar_invite_v2',
1087+
'google_calendar_respond_v2',
10491088
'google_calendar_freebusy_v2',
10501089
'google_calendar_create_calendar_v2',
10511090
'google_calendar_update_calendar_v2',
@@ -1079,6 +1118,8 @@ export const GoogleCalendarV2Block: BlockConfig<GoogleCalendarResponse> = {
10791118
attendees: { type: 'json', description: 'Event attendees' },
10801119
creator: { type: 'json', description: 'Event creator' },
10811120
organizer: { type: 'json', description: 'Event organizer' },
1121+
responseStatus: { type: 'string', description: 'Your RSVP response (respond operation)' },
1122+
comment: { type: 'string', description: 'Your RSVP note (respond operation)' },
10821123
events: { type: 'json', description: 'List of events (list operation)' },
10831124
eventId: { type: 'string', description: 'Deleted event ID' },
10841125
deleted: { type: 'boolean', description: 'Whether deletion/removal was successful' },
@@ -1197,6 +1238,12 @@ export const GoogleCalendarBlockMeta = {
11971238
content:
11981239
'# Invite Attendees to an Event\n\nAdd people to an event without recreating it.\n\n## Steps\n1. Obtain the event ID (use List Events to find it if needed).\n2. Collect the attendee emails to add as a comma-separated list.\n3. Run Invite Attendees with Replace Existing set to `Add to existing attendees` (unless asked to replace the whole list).\n4. Set Send Email Notifications to `all`.\n\n## Output\nConfirm the added attendees and the resulting full attendee list, with the event link.',
11991240
},
1241+
{
1242+
name: 'rsvp-to-event',
1243+
description: 'Accept, decline, or tentatively accept a Google Calendar invitation.',
1244+
content:
1245+
'# RSVP to an Event\n\nChange your own response to an invitation without touching the guest list.\n\n## Steps\n1. Find the event with List Events over the right time window. List Events returns each occurrence of a recurring meeting with its own ID, so responding with that ID changes only that occurrence.\n2. Run Respond to Invitation (RSVP) with Response set to `Yes (accept)`, `No (decline)`, or `Maybe (tentative)`, and an optional note.\n3. Do not use Update Event or Invite Attendees to RSVP; they edit the guest list, not your response.\n\n## Output\nConfirm the event title, the occurrence start time, and the confirmed response returned by the tool.',
1246+
},
12001247
],
12011248
} as const satisfies BlockMeta
12021249

apps/sim/tools/generated/tool-ids.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/generated/tool-outputs.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/google_calendar/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { listAclTool, listAclV2Tool } from '@/tools/google_calendar/list_acl'
1111
import { listCalendarsTool, listCalendarsV2Tool } from '@/tools/google_calendar/list_calendars'
1212
import { moveTool, moveV2Tool } from '@/tools/google_calendar/move'
1313
import { quickAddTool, quickAddV2Tool } from '@/tools/google_calendar/quick_add'
14+
import { respondTool, respondV2Tool } from '@/tools/google_calendar/respond'
1415
import { shareCalendarTool, shareCalendarV2Tool } from '@/tools/google_calendar/share_calendar'
1516
import {
1617
unshareCalendarTool,
@@ -33,6 +34,7 @@ export const googleCalendarListAclTool = listAclTool
3334
export const googleCalendarListCalendarsTool = listCalendarsTool
3435
export const googleCalendarMoveTool = moveTool
3536
export const googleCalendarQuickAddTool = quickAddTool
37+
export const googleCalendarRespondTool = respondTool
3638
export const googleCalendarShareCalendarTool = shareCalendarTool
3739
export const googleCalendarUnshareCalendarTool = unshareCalendarTool
3840
export const googleCalendarUpdateTool = updateTool
@@ -52,6 +54,7 @@ export const googleCalendarListAclV2Tool = listAclV2Tool
5254
export const googleCalendarListCalendarsV2Tool = listCalendarsV2Tool
5355
export const googleCalendarMoveV2Tool = moveV2Tool
5456
export const googleCalendarQuickAddV2Tool = quickAddV2Tool
57+
export const googleCalendarRespondV2Tool = respondV2Tool
5558
export const googleCalendarShareCalendarV2Tool = shareCalendarV2Tool
5659
export const googleCalendarUnshareCalendarV2Tool = unshareCalendarV2Tool
5760
export const googleCalendarUpdateV2Tool = updateV2Tool
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { respondTool, respondV2Tool } from '@/tools/google_calendar/respond'
6+
import type { GoogleCalendarRespondParams } from '@/tools/google_calendar/types'
7+
8+
const baseParams: GoogleCalendarRespondParams = {
9+
accessToken: 'token',
10+
calendarId: 'primary',
11+
eventId: 'series123_20260921T170000Z',
12+
responseStatus: 'accepted',
13+
}
14+
15+
function eventWith(selfStatus: string, selfComment?: string) {
16+
return {
17+
id: 'series123_20260921T170000Z',
18+
status: 'confirmed',
19+
htmlLink: 'https://calendar.google.com/event?eid=abc',
20+
summary: 'Monday eng',
21+
recurringEventId: 'series123',
22+
start: { dateTime: '2026-09-21T10:00:00-07:00' },
23+
end: { dateTime: '2026-09-21T10:30:00-07:00' },
24+
organizer: { email: 'lead@example.com' },
25+
attendees: [
26+
{ email: 'lead@example.com', organizer: true, responseStatus: 'accepted' },
27+
{ email: 'me@example.com', self: true, responseStatus: selfStatus, comment: selfComment },
28+
{ email: 'peer@example.com', responseStatus: 'tentative' },
29+
],
30+
}
31+
}
32+
33+
function jsonResponse(body: unknown, status = 200) {
34+
return new Response(JSON.stringify(body), {
35+
status,
36+
headers: { 'Content-Type': 'application/json' },
37+
})
38+
}
39+
40+
afterEach(() => {
41+
vi.unstubAllGlobals()
42+
})
43+
44+
describe('google_calendar_respond', () => {
45+
it('reads the event occurrence it is given', () => {
46+
const url = (respondTool.request.url as (p: GoogleCalendarRespondParams) => string)(baseParams)
47+
expect(url).toBe(
48+
'https://www.googleapis.com/calendar/v3/calendars/primary/events/series123_20260921T170000Z'
49+
)
50+
expect(respondTool.request.method).toBe('GET')
51+
})
52+
53+
it('patches only the self attendee with attendeesOmitted and verifies the result', async () => {
54+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(eventWith('accepted', 'See you')))
55+
vi.stubGlobal('fetch', fetchMock)
56+
57+
const result = await respondV2Tool.transformResponse!(jsonResponse(eventWith('needsAction')), {
58+
...baseParams,
59+
comment: ' See you ',
60+
sendUpdates: 'all',
61+
})
62+
63+
expect(fetchMock).toHaveBeenCalledTimes(1)
64+
const [url, init] = fetchMock.mock.calls[0]
65+
expect(url).toBe(
66+
'https://www.googleapis.com/calendar/v3/calendars/primary/events/series123_20260921T170000Z?sendUpdates=all'
67+
)
68+
expect(init.method).toBe('PATCH')
69+
expect(JSON.parse(init.body)).toEqual({
70+
attendeesOmitted: true,
71+
attendees: [{ email: 'me@example.com', responseStatus: 'accepted', comment: 'See you' }],
72+
})
73+
expect(result.output).toMatchObject({
74+
id: 'series123_20260921T170000Z',
75+
responseStatus: 'accepted',
76+
comment: 'See you',
77+
})
78+
})
79+
80+
it('omits sendUpdates and comment when not provided', async () => {
81+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(eventWith('declined')))
82+
vi.stubGlobal('fetch', fetchMock)
83+
84+
const result = await respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), {
85+
...baseParams,
86+
responseStatus: 'declined',
87+
})
88+
89+
const [url, init] = fetchMock.mock.calls[0]
90+
expect(url).not.toContain('?')
91+
expect(JSON.parse(init.body).attendees).toEqual([
92+
{ email: 'me@example.com', responseStatus: 'declined' },
93+
])
94+
expect(result.output.content).toBe('Response to "Monday eng" set to declined')
95+
})
96+
97+
it('fails when the calendar is not an attendee', async () => {
98+
const fetchMock = vi.fn()
99+
vi.stubGlobal('fetch', fetchMock)
100+
const event = { ...eventWith('needsAction'), attendees: [{ email: 'x@example.com' }] }
101+
102+
await expect(respondTool.transformResponse!(jsonResponse(event), baseParams)).rejects.toThrow(
103+
/not an attendee/
104+
)
105+
expect(fetchMock).not.toHaveBeenCalled()
106+
})
107+
108+
it('rejects an unsupported response status before writing', async () => {
109+
const fetchMock = vi.fn()
110+
vi.stubGlobal('fetch', fetchMock)
111+
112+
await expect(
113+
respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), {
114+
...baseParams,
115+
responseStatus: 'needsAction' as GoogleCalendarRespondParams['responseStatus'],
116+
})
117+
).rejects.toThrow(/Invalid response status/)
118+
expect(fetchMock).not.toHaveBeenCalled()
119+
})
120+
121+
it('surfaces the Google error when the patch is rejected', async () => {
122+
vi.stubGlobal(
123+
'fetch',
124+
vi.fn().mockResolvedValue(jsonResponse({ error: { message: 'Forbidden' } }, 403))
125+
)
126+
127+
await expect(
128+
respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), baseParams)
129+
).rejects.toThrow('Forbidden')
130+
})
131+
132+
it('never writes the RSVP when the run was canceled', async () => {
133+
const fetchMock = vi.fn()
134+
vi.stubGlobal('fetch', fetchMock)
135+
const controller = new AbortController()
136+
controller.abort()
137+
138+
await expect(
139+
respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), baseParams, {
140+
signal: controller.signal,
141+
})
142+
).rejects.toThrow()
143+
expect(fetchMock).not.toHaveBeenCalled()
144+
})
145+
146+
it('forwards the execution signal to the RSVP write', async () => {
147+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(eventWith('accepted')))
148+
vi.stubGlobal('fetch', fetchMock)
149+
const controller = new AbortController()
150+
151+
await respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), baseParams, {
152+
signal: controller.signal,
153+
})
154+
expect(fetchMock.mock.calls[0][1].signal).toBe(controller.signal)
155+
})
156+
157+
it('fails when Google applies the response but drops the note', async () => {
158+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(eventWith('accepted'))))
159+
160+
await expect(
161+
respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), {
162+
...baseParams,
163+
comment: 'See you',
164+
})
165+
).rejects.toThrow(/did not save the response note/)
166+
})
167+
168+
it('fails when Google does not reflect the new response', async () => {
169+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(eventWith('needsAction'))))
170+
171+
await expect(
172+
respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), baseParams)
173+
).rejects.toThrow(/did not apply the response/)
174+
})
175+
})

0 commit comments

Comments
 (0)