feat: create calendar events on Google and Microsoft accounts (#22231)

## Context

Twenty can import calendar events and send emails, but cannot create
calendar events. This adds calendar event creation on connected
**Google** and **Microsoft** accounts, mirroring the existing email-send
architecture (`message-outbound-manager`).

## What it adds

The capability is exposed three ways, all backed by the same composer →
driver → persist pipeline:

- **GraphQL mutation** `createCalendarEvent` (metadata API)
- **AI agent tool** `create_calendar_event` (flows to MCP
automatically), gated by a new `CREATE_CALENDAR_EVENT_TOOL` permission
flag
- **Workflow builder node** "Create Calendar Event" in the **Core**
section, with a full settings form (variable interpolation supported)

CalDAV/IMAP is intentionally out of scope for now (different long pole).

## Design notes

- **Reuse over reinvention** — the created event is run through the
existing inbound formatters (`formatGoogleCalendarEvents` /
`formatMicrosoftCalendarEvents`) and persisted immediately via the
existing `CalendarSaveEventsService`, so it appears in Twenty right away
and is reconciled by the next provider sync (dedup on external id).
Persistence is best-effort.
- **OAuth scopes** — Google already requests `calendar.events`
(read+write), so no change there. Microsoft moves `Calendars.Read` →
`Calendars.ReadWrite`; existing Microsoft accounts must re-consent
(surfaced as a clear "reconnect" error via a missing-scope check).
- **Deliberate invitation semantics** — `sendInvitations` is off by
default. When off, the event is created with **no attendees** on either
provider, so creating an event never silently emails external people.
When on, attendees are attached and notified (Google `sendUpdates: all`,
Microsoft's default). This sidesteps Microsoft Graph having no
per-request suppression.
- **Timezone correctness** — Microsoft Graph interprets `dateTime` as
wall-clock in the supplied `timeZone` and ignores the offset, so the
absolute instant is converted to its wall-clock form before sending
(Google honors the offset directly). Both providers end up scheduling
the same instant.
- **Conferencing** — optional Google Meet
(`conferenceData.createRequest`, with a follow-up `events.get` to
resolve the async link) / Microsoft Teams (`isOnlineMeeting`).
- Attendees are a comma-separated string everywhere (tool input, GraphQL
DTO, workflow input), consistent with `send_email` recipients; the
composer parses to its internal list.

## Test plan

- **Unit**: 45 tests covering the composer (validation, all-day
boundaries, offset enforcement, timezone, scope checks, default-account
resolution), both provider drivers, the dispatcher, and the workflow
step-log builder.
- **Integration**: `createCalendarEvent` on the `/metadata` API fails
closed with a structured error for a non-existent account (the
auth/ownership/validation path that doesn't require provider mocking).
- **Manual**: verified the workflow node appears in the Core section,
the settings form renders and round-trips (edit → autosave → reload),
and the live mutation returns a structured failure for a bogus account.

## Open question for reviewers

The metadata mutation `createCalendarEvent` shares a name with the core
schema's auto-generated `createCalendarEvent(data:)` CRUD mutation for
the CalendarEvent object — they live on different endpoints (`/metadata`
vs `/graphql`) so there's no runtime conflict, but it's a potential
point of confusion for API consumers. Happy to rename (e.g.
`createCalendarEventOnConnectedAccount`) if preferred.

## Out of scope / follow-ups

- CalDAV/IMAP support
- Event update/delete and recurrence
- Existing Microsoft accounts need re-consent for the widened scope


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22231?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: neo773 <neo773@protonmail.com>
This commit is contained in:
Félix Malfait
2026-06-27 14:05:58 +02:00
committed by GitHub
parent 2662fda647
commit 0e22ae0521
86 changed files with 4991 additions and 1824 deletions
@@ -6,7 +6,7 @@ export const getMicrosoftApisOauthScopes = () => {
'offline_access',
'Mail.ReadWrite',
'Mail.Send',
'Calendars.Read',
'Calendars.ReadWrite',
'User.Read',
];
@@ -74,6 +74,7 @@ import { CodeInterpreterSessionCleanupModule } from 'src/engine/core-modules/cod
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
import { CreateCalendarEventModule } from 'src/modules/calendar/calendar-event-creation-manager/create-calendar-event.module';
import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
import { SendEmailModule } from 'src/modules/messaging/message-outbound-manager/send-email.module';
import { ClientConfigModule } from './client-config/client-config.module';
@@ -130,6 +131,7 @@ import { FileModule } from './file/file.module';
ImapSmtpCaldavModule,
ChannelSyncModule,
SendEmailModule,
CreateCalendarEventModule,
FileStorageModule.forRoot(),
LoggerModule.forRootAsync({
useFactory: loggerModuleFactory,
@@ -7,6 +7,7 @@ export const ACTION_TOOL_IDS = [
'http_request',
'send_email',
'draft_email',
'create_calendar_event',
'search_help_center',
'code_interpreter',
'navigate_app',
@@ -24,6 +25,9 @@ export const ACTION_TOOL_LABELS: Record<ActionToolId, ActionToolLabel> = {
draft_email: {
label: i18nLabel(msg`Draft Email`),
},
create_calendar_event: {
label: i18nLabel(msg`Create Calendar Event`),
},
search_help_center: {
label: i18nLabel(msg`Search Help Center`),
},
@@ -21,6 +21,7 @@ import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-t
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
import { CreateCalendarEventTool } from 'src/engine/core-modules/tool/tools/calendar-tool/create-calendar-event-tool';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
@@ -43,6 +44,7 @@ export class ActionToolProvider implements ToolProvider {
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly draftEmailTool: DraftEmailTool,
private readonly createCalendarEventTool: CreateCalendarEventTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly codeInterpreterTool: CodeInterpreterTool,
private readonly navigateAppTool: NavigateAppTool,
@@ -56,6 +58,7 @@ export class ActionToolProvider implements ToolProvider {
['http_request', this.httpTool],
['send_email', this.sendEmailTool],
['draft_email', this.draftEmailTool],
['create_calendar_event', this.createCalendarEventTool],
['search_help_center', this.searchHelpCenterTool],
['code_interpreter', this.codeInterpreterTool],
['navigate_app', this.navigateAppTool],
@@ -117,6 +120,24 @@ export class ActionToolProvider implements ToolProvider {
);
}
const hasCreateCalendarEventPermission =
await this.permissionsService.hasToolPermission(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.CREATE_CALENDAR_EVENT_TOOL,
);
if (hasCreateCalendarEventPermission) {
descriptors.push(
this.buildDescriptor(
'create_calendar_event',
this.createCalendarEventTool,
includeSchemas,
context.locale,
),
);
}
descriptors.push(
this.buildDescriptor(
'search_help_center',
@@ -7,6 +7,7 @@ import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { CreateCalendarEventTool } from 'src/engine/core-modules/tool/tools/calendar-tool/create-calendar-event-tool';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
@@ -22,6 +23,7 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { CalendarEventCreationManagerModule } from 'src/modules/calendar/calendar-event-creation-manager/calendar-event-creation-manager.module';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbound-manager/messaging-send-manager.module';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
@@ -29,6 +31,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
imports: [
MessagingImportManagerModule,
MessagingSendManagerModule,
CalendarEventCreationManagerModule,
TypeOrmModule.forFeature([FileEntity, ConnectedAccountEntity]),
ApplicationModule,
FeatureFlagModule,
@@ -44,6 +47,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
HttpTool,
SendEmailTool,
DraftEmailTool,
CreateCalendarEventTool,
EmailComposerService,
SearchHelpCenterTool,
CodeInterpreterTool,
@@ -57,6 +61,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
HttpTool,
SendEmailTool,
DraftEmailTool,
CreateCalendarEventTool,
EmailComposerService,
SearchHelpCenterTool,
CodeInterpreterTool,
@@ -0,0 +1,60 @@
import { isValidUuid } from 'twenty-shared/utils';
import { z } from 'zod';
export const CreateCalendarEventToolInputZodSchema = z.object({
title: z.string().describe('The title of the calendar event'),
description: z
.string()
.describe('The event description or agenda')
.optional(),
location: z
.string()
.describe('The physical or virtual location of the event')
.optional(),
startsAt: z
.string()
.describe(
'Event start time as an ISO 8601 date-time with an offset (e.g. 2026-07-01T15:00:00Z). For all-day events pass a date (e.g. 2026-07-01).',
),
endsAt: z
.string()
.describe(
'Event end time as an ISO 8601 date-time with an offset, after startsAt. For all-day events pass the exclusive end date (the day after the last day).',
),
isFullDay: z
.boolean()
.describe('Whether the event lasts the whole day')
.default(false),
timeZone: z
.string()
.describe(
'IANA time zone for the event (e.g. America/New_York). Defaults to UTC.',
)
.optional(),
attendees: z
.string()
.describe(
'Comma-separated attendee email addresses. Only applied when sendInvitations is true; otherwise ignored.',
)
.optional()
.default(''),
sendInvitations: z
.boolean()
.describe(
'When true, attendees are added to the event and emailed an invitation. When false, the event is created with no attendees and nobody is notified.',
)
.default(false),
addConferencing: z
.boolean()
.describe(
'When true, a video conferencing link is generated (Google Meet for Google accounts, Microsoft Teams for Microsoft accounts).',
)
.default(false),
connectedAccountId: z
.string()
.refine((val) => isValidUuid(val))
.describe(
'The UUID of the connected account to create the event on. Provide only if known; otherwise leave blank to use the default calendar account.',
)
.optional(),
});
@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import { CreateCalendarEventToolInputZodSchema } from 'src/engine/core-modules/tool/tools/calendar-tool/calendar-tool.schema';
import { type CreateCalendarEventToolInput } from 'src/engine/core-modules/tool/tools/calendar-tool/types/create-calendar-event-tool-input.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { CalendarEventCreationException } from 'src/modules/calendar/calendar-event-creation-manager/exceptions/calendar-event-creation.exception';
import { CalendarEventComposerService } from 'src/modules/calendar/calendar-event-creation-manager/services/calendar-event-composer.service';
import { CreateCalendarEventService } from 'src/modules/calendar/calendar-event-creation-manager/services/create-calendar-event.service';
@Injectable()
export class CreateCalendarEventTool implements Tool {
private readonly logger = new Logger(CreateCalendarEventTool.name);
description =
'Create a calendar event on a connected Google or Microsoft account. Requires CREATE_CALENDAR_EVENT_TOOL permission. Set sendInvitations to true to attach attendees and email them an invitation; when false the event is created with no attendees and nobody is notified.';
inputSchema = CreateCalendarEventToolInputZodSchema;
flag = PermissionFlagType.CREATE_CALENDAR_EVENT_TOOL;
constructor(
private readonly calendarEventComposerService: CalendarEventComposerService,
private readonly createCalendarEventService: CreateCalendarEventService,
) {}
async execute(
parameters: CreateCalendarEventToolInput,
context: ToolExecutionContext,
): Promise<ToolOutput> {
try {
const result =
await this.calendarEventComposerService.composeCalendarEvent(
parameters,
context.workspaceId,
);
if (!result.success) {
return {
success: false,
message: 'Failed to create calendar event',
error: result.error,
};
}
const { data } = result;
const createdEvent =
await this.createCalendarEventService.createComposedCalendarEvent(data);
await this.createCalendarEventService.persistCalendarEvent(
createdEvent,
data,
context.workspaceId,
);
this.logger.log(
`Calendar event "${createdEvent.title}" created on connected account ${data.connectedAccount.id}`,
);
return {
success: true,
message: `Calendar event "${createdEvent.title}" created`,
result: {
iCalUid: createdEvent.iCalUid,
externalEventId: createdEvent.id,
title: createdEvent.title,
startsAt: createdEvent.startsAt,
endsAt: createdEvent.endsAt,
conferenceLink: createdEvent.conferenceLinkUrl || undefined,
attendeeCount: createdEvent.participants.length,
connectedAccountId: data.connectedAccount.id,
},
};
} catch (error) {
if (error instanceof CalendarEventCreationException) {
return {
success: false,
message: 'Failed to create calendar event',
error: error.message,
};
}
this.logger.error(`Failed to create calendar event: ${error}`);
return {
success: false,
message: 'Failed to create calendar event',
error:
error instanceof Error
? error.message
: 'Failed to create calendar event',
};
}
}
}
@@ -0,0 +1,7 @@
import { type z } from 'zod';
import { type CreateCalendarEventToolInputZodSchema } from 'src/engine/core-modules/tool/tools/calendar-tool/calendar-tool.schema';
export type CreateCalendarEventToolInput = z.infer<
typeof CreateCalendarEventToolInputZodSchema
>;