Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/docs/content/docs/integrations/google_calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,35 @@ Invite attendees to an existing Google Calendar event. Returns API-aligned field
| `creator` | json | Event creator |
| `organizer` | json | Event organizer |

### Google Calendar Respond to Invitation

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.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `calendarId` | string | No | Google Calendar ID the invitation appears on \(e.g., primary or calendar@group.calendar.google.com\) |
| `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. |
| `responseStatus` | string | Yes | Your response: accepted, declined, or tentative |
| `comment` | string | No | Optional note to include with your response |
| `sendUpdates` | string | No | Who to notify about your response: all, externalOnly, or none |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Event ID |
| `htmlLink` | string | Event link |
| `status` | string | Event status |
| `summary` | string | Event title |
| `start` | json | Event start |
| `end` | json | Event end |
| `responseStatus` | string | Your confirmed response \(accepted, declined, or tentative\) |
| `comment` | string | Your response comment |
| `attendees` | json | Event attendees |
| `organizer` | json | Event organizer |

### Google Calendar Free/Busy

Query free/busy information for one or more Google Calendars. Returns API-aligned fields only.
Expand Down
53 changes: 50 additions & 3 deletions apps/sim/blocks/blocks/google_calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
{ text: 'Invite', field: 'attendees', core: true },
{ text: 'to event', field: 'eventId', core: true },
],
respond: [
{ text: 'Respond', field: 'responseStatus', core: true },
{ text: 'to event', field: 'eventId', core: true },
],
freebusy: [
{ text: 'Check free/busy for', field: 'calendarIds', core: true },
{ text: ', starting', field: 'timeMin' },
Expand Down Expand Up @@ -138,6 +142,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
{ label: 'List Calendars', id: 'list_calendars' },
{ label: 'Quick Add (Natural Language)', id: 'quick_add' },
{ label: 'Invite Attendees', id: 'invite' },
{ label: 'Respond to Invitation (RSVP)', id: 'respond' },
{ label: 'Check Free/Busy', id: 'freebusy' },
{ label: 'Create Calendar', id: 'create_calendar' },
{ label: 'Update Calendar', id: 'update_calendar' },
Expand Down Expand Up @@ -415,7 +420,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Event ID',
condition: {
field: 'operation',
value: ['get', 'update', 'delete', 'move', 'instances', 'invite'],
value: ['get', 'update', 'delete', 'move', 'instances', 'invite', 'respond'],
},
required: true,
},
Expand Down Expand Up @@ -618,6 +623,28 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
],
},

{
id: 'responseStatus',
title: 'Response',
type: 'dropdown',
condition: { field: 'operation', value: 'respond' },
required: true,
options: [
{ label: 'Yes (accept)', id: 'accepted' },
{ label: 'No (decline)', id: 'declined' },
{ label: 'Maybe (tentative)', id: 'tentative' },
],
value: () => 'accepted',
},
{
id: 'comment',
title: 'Response Note',
type: 'short-input',
placeholder: 'Running 5 minutes late',
condition: { field: 'operation', value: 'respond' },
mode: 'advanced',
},

{
id: 'text',
title: 'Natural Language Event',
Expand Down Expand Up @@ -820,7 +847,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
type: 'dropdown',
condition: {
field: 'operation',
value: ['create', 'update', 'delete', 'move', 'quick_add', 'invite'],
value: ['create', 'update', 'delete', 'move', 'quick_add', 'invite', 'respond'],
},
options: [
{ label: 'All attendees', id: 'all' },
Expand All @@ -842,6 +869,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
'google_calendar_list_calendars',
'google_calendar_quick_add',
'google_calendar_invite',
'google_calendar_respond',
'google_calendar_freebusy',
'google_calendar_create_calendar',
'google_calendar_update_calendar',
Expand Down Expand Up @@ -874,6 +902,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
return 'google_calendar_quick_add'
case 'invite':
return 'google_calendar_invite'
case 'respond':
return 'google_calendar_respond'
case 'freebusy':
return 'google_calendar_freebusy'
case 'create_calendar':
Expand Down Expand Up @@ -950,7 +980,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}

if (
['create', 'update', 'delete', 'move', 'quick_add', 'invite'].includes(operation) &&
['create', 'update', 'delete', 'move', 'quick_add', 'invite', 'respond'].includes(
operation
) &&
!processedParams.sendUpdates
) {
processedParams.sendUpdates = 'all'
Expand Down Expand Up @@ -1014,6 +1046,12 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,

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

responseStatus: {
type: 'string',
description: 'RSVP response (accepted, declined, or tentative)',
},
comment: { type: 'string', description: 'Note included with the RSVP response' },

sendUpdates: { type: 'string', description: 'Send email notifications' },
},
outputs: {
Expand Down Expand Up @@ -1046,6 +1084,7 @@ export const GoogleCalendarV2Block: BlockConfig<GoogleCalendarResponse> = {
'google_calendar_list_calendars_v2',
'google_calendar_quick_add_v2',
'google_calendar_invite_v2',
'google_calendar_respond_v2',
'google_calendar_freebusy_v2',
'google_calendar_create_calendar_v2',
'google_calendar_update_calendar_v2',
Expand Down Expand Up @@ -1079,6 +1118,8 @@ export const GoogleCalendarV2Block: BlockConfig<GoogleCalendarResponse> = {
attendees: { type: 'json', description: 'Event attendees' },
creator: { type: 'json', description: 'Event creator' },
organizer: { type: 'json', description: 'Event organizer' },
responseStatus: { type: 'string', description: 'Your RSVP response (respond operation)' },
comment: { type: 'string', description: 'Your RSVP note (respond operation)' },
events: { type: 'json', description: 'List of events (list operation)' },
eventId: { type: 'string', description: 'Deleted event ID' },
deleted: { type: 'boolean', description: 'Whether deletion/removal was successful' },
Expand Down Expand Up @@ -1197,6 +1238,12 @@ export const GoogleCalendarBlockMeta = {
content:
'# 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.',
},
{
name: 'rsvp-to-event',
description: 'Accept, decline, or tentatively accept a Google Calendar invitation.',
content:
'# 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.',
},
],
} as const satisfies BlockMeta

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-ids.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-metadata.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-outputs.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/sim/tools/google_calendar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { listAclTool, listAclV2Tool } from '@/tools/google_calendar/list_acl'
import { listCalendarsTool, listCalendarsV2Tool } from '@/tools/google_calendar/list_calendars'
import { moveTool, moveV2Tool } from '@/tools/google_calendar/move'
import { quickAddTool, quickAddV2Tool } from '@/tools/google_calendar/quick_add'
import { respondTool, respondV2Tool } from '@/tools/google_calendar/respond'
import { shareCalendarTool, shareCalendarV2Tool } from '@/tools/google_calendar/share_calendar'
import {
unshareCalendarTool,
Expand All @@ -33,6 +34,7 @@ export const googleCalendarListAclTool = listAclTool
export const googleCalendarListCalendarsTool = listCalendarsTool
export const googleCalendarMoveTool = moveTool
export const googleCalendarQuickAddTool = quickAddTool
export const googleCalendarRespondTool = respondTool
export const googleCalendarShareCalendarTool = shareCalendarTool
export const googleCalendarUnshareCalendarTool = unshareCalendarTool
export const googleCalendarUpdateTool = updateTool
Expand All @@ -52,6 +54,7 @@ export const googleCalendarListAclV2Tool = listAclV2Tool
export const googleCalendarListCalendarsV2Tool = listCalendarsV2Tool
export const googleCalendarMoveV2Tool = moveV2Tool
export const googleCalendarQuickAddV2Tool = quickAddV2Tool
export const googleCalendarRespondV2Tool = respondV2Tool
export const googleCalendarShareCalendarV2Tool = shareCalendarV2Tool
export const googleCalendarUnshareCalendarV2Tool = unshareCalendarV2Tool
export const googleCalendarUpdateV2Tool = updateV2Tool
Expand Down
175 changes: 175 additions & 0 deletions apps/sim/tools/google_calendar/respond.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { respondTool, respondV2Tool } from '@/tools/google_calendar/respond'
import type { GoogleCalendarRespondParams } from '@/tools/google_calendar/types'

const baseParams: GoogleCalendarRespondParams = {
accessToken: 'token',
calendarId: 'primary',
eventId: 'series123_20260921T170000Z',
responseStatus: 'accepted',
}

function eventWith(selfStatus: string, selfComment?: string) {
return {
id: 'series123_20260921T170000Z',
status: 'confirmed',
htmlLink: 'https://calendar.google.com/event?eid=abc',
summary: 'Monday eng',
recurringEventId: 'series123',
start: { dateTime: '2026-09-21T10:00:00-07:00' },
end: { dateTime: '2026-09-21T10:30:00-07:00' },
organizer: { email: 'lead@example.com' },
attendees: [
{ email: 'lead@example.com', organizer: true, responseStatus: 'accepted' },
{ email: 'me@example.com', self: true, responseStatus: selfStatus, comment: selfComment },
{ email: 'peer@example.com', responseStatus: 'tentative' },
],
}
}

function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}

afterEach(() => {
vi.unstubAllGlobals()
})

describe('google_calendar_respond', () => {
it('reads the event occurrence it is given', () => {
const url = (respondTool.request.url as (p: GoogleCalendarRespondParams) => string)(baseParams)
expect(url).toBe(
'https://www.googleapis.com/calendar/v3/calendars/primary/events/series123_20260921T170000Z'
)
expect(respondTool.request.method).toBe('GET')
})

it('patches only the self attendee with attendeesOmitted and verifies the result', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(eventWith('accepted', 'See you')))
vi.stubGlobal('fetch', fetchMock)

const result = await respondV2Tool.transformResponse!(jsonResponse(eventWith('needsAction')), {
...baseParams,
comment: ' See you ',
sendUpdates: 'all',
})

expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe(
'https://www.googleapis.com/calendar/v3/calendars/primary/events/series123_20260921T170000Z?sendUpdates=all'
)
expect(init.method).toBe('PATCH')
expect(JSON.parse(init.body)).toEqual({
attendeesOmitted: true,
attendees: [{ email: 'me@example.com', responseStatus: 'accepted', comment: 'See you' }],
})
expect(result.output).toMatchObject({
id: 'series123_20260921T170000Z',
responseStatus: 'accepted',
comment: 'See you',
})
})

it('omits sendUpdates and comment when not provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(eventWith('declined')))
vi.stubGlobal('fetch', fetchMock)

const result = await respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), {
...baseParams,
responseStatus: 'declined',
})

const [url, init] = fetchMock.mock.calls[0]
expect(url).not.toContain('?')
expect(JSON.parse(init.body).attendees).toEqual([
{ email: 'me@example.com', responseStatus: 'declined' },
])
expect(result.output.content).toBe('Response to "Monday eng" set to declined')
})

it('fails when the calendar is not an attendee', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const event = { ...eventWith('needsAction'), attendees: [{ email: 'x@example.com' }] }

await expect(respondTool.transformResponse!(jsonResponse(event), baseParams)).rejects.toThrow(
/not an attendee/
)
expect(fetchMock).not.toHaveBeenCalled()
})

it('rejects an unsupported response status before writing', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)

await expect(
respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), {
...baseParams,
responseStatus: 'needsAction' as GoogleCalendarRespondParams['responseStatus'],
})
).rejects.toThrow(/Invalid response status/)
expect(fetchMock).not.toHaveBeenCalled()
})

it('surfaces the Google error when the patch is rejected', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(jsonResponse({ error: { message: 'Forbidden' } }, 403))
)

await expect(
respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), baseParams)
).rejects.toThrow('Forbidden')
})

it('never writes the RSVP when the run was canceled', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
controller.abort()

await expect(
respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), baseParams, {
signal: controller.signal,
})
).rejects.toThrow()
expect(fetchMock).not.toHaveBeenCalled()
})

it('forwards the execution signal to the RSVP write', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(eventWith('accepted')))
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()

await respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), baseParams, {
signal: controller.signal,
})
expect(fetchMock.mock.calls[0][1].signal).toBe(controller.signal)
})

it('fails when Google applies the response but drops the note', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(eventWith('accepted'))))

await expect(
respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), {
...baseParams,
comment: 'See you',
})
).rejects.toThrow(/did not save the response note/)
})

it('fails when Google does not reflect the new response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(eventWith('needsAction'))))

await expect(
respondTool.transformResponse!(jsonResponse(eventWith('needsAction')), baseParams)
).rejects.toThrow(/did not apply the response/)
})
})
Loading
Loading