import { defineAction, embedApp, isOutsideAgentCall } from "@agent-native/core"; import { fail } from "@agent-native/core/action"; import { readAppState, writeAppState, compareAndSetAppState, deleteAppState, deleteAppStateByPrefix, } from "@agent-native/core/application-state"; import { buildDeepLink, getRequestUserEmail } from "@agent-native/core/server"; import { z } from "zod"; import type { CalendarEventDraft } from "../shared/api.js"; import { autoDeclineModeInput, attachmentsInput, attendeesInput, availabilityInput, buildReminderOverrides, cliBoolean, eventTypeInput, googleColorIdInput, normalizeAttendees, normalizeCreateEventInput, normalizeRecurrence, reminderMethodInput, reminderMinutesInput, remindersInput, visibilityInput, workingLocationTypeInput, } from "./event-action-helpers.js"; const DRAFT_PREFIX = "calendar-draft-"; function sanitizeDraftId(id: string): string | null { return /^[a-zA-Z0-9_-]{1,64}$/.test(id) ? id : null; } function draftKey(id: string) { return `${DRAFT_PREFIX}${id}`; } function failDraftIdTaken(id: string): never { fail( `Calendar event draft "${id}" already exists; use action "update" to change it, or omit id to create a new draft.`, { errorCode: "calendar_draft_id_taken", statusCode: 409 }, ); } function eventDraftDeepLink(draft: CalendarEventDraft): string { return buildDeepLink({ app: "calendar", view: "calendar", to: "/home", params: { eventDraftId: draft.id, date: draft.start?.slice(0, 10), }, }); } function setIfPresent( target: CalendarEventDraft, key: T, value: CalendarEventDraft[T] | undefined, ) { if (value !== undefined) target[key] = value; } export default defineAction({ mcpTool: true, needsApproval: (args, ctx) => args.action === "delete-all" && isOutsideAgentCall(ctx), mcpAnnotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, description: "Create, update, or delete an unsent calendar invite draft. Opening a draft shows a visible placeholder on the calendar with the event detail editor so the user can review it before creating/sending.", schema: z.object({ action: z .enum(["create", "update", "delete", "delete-all"]) .optional() .describe("Action to perform. Defaults to create."), id: z .string() .optional() .describe( "Draft ID (auto-generated for create; required for update/delete)", ), title: z.string().optional().describe("Event title"), start: z .string() .optional() .describe( "Start time in ISO format, or the first inclusive YYYY-MM-DD date for a full-day OOO draft.", ), end: z .string() .optional() .describe( "End time in ISO format, or the last inclusive YYYY-MM-DD date for a full-day OOO draft.", ), startTimeZone: z .string() .optional() .describe("IANA timezone for the event start, e.g. America/New_York"), endTimeZone: z .string() .optional() .describe("IANA timezone for the event end, e.g. America/New_York"), description: z.string().optional().describe("Event description"), location: z.string().optional().describe("Event location"), allDay: cliBoolean.optional().describe("Whether the event is all-day"), fullDay: cliBoolean .optional() .describe( "For an out-of-office draft, cover the full inclusive start/end dates while creating a Google-compatible timed event.", ), eventType: eventTypeInput.describe( "Native Google Calendar event type: default, outOfOffice, focusTime, or workingLocation.", ), transparency: availabilityInput.describe( "Google Calendar availability: opaque blocks time (Busy), transparent does not block time (Free).", ), visibility: visibilityInput.describe( "Google Calendar visibility: default, public, private, or confidential.", ), autoDeclineMode: autoDeclineModeInput.describe( "For an out-of-office draft: decline all conflicts, only new conflicts, or none.", ), declineMessage: z .string() .optional() .describe("For an out-of-office draft: decline response message."), remindersUseDefault: cliBoolean .optional() .describe( "Whether to use calendar default reminders. Set false with no reminders for no alerts.", ), reminders: remindersInput.describe( "Custom reminder overrides, max 5, such as [{method:'popup', minutes:10}].", ), recurrence: z .union([z.string(), z.array(z.string())]) .optional() .describe( "Google recurrence rules, such as RRULE:FREQ=DAILY. Pass an empty string or [] to clear recurrence.", ), reminderMinutes: reminderMinutesInput.describe( "Convenience field for a single reminder in minutes before the event.", ), reminderMethod: reminderMethodInput.describe( "Reminder method for reminderMinutes. Defaults to popup.", ), attachments: attachmentsInput.describe( "Google Calendar attachments, max 25. Use Drive or https file URLs, e.g. [{fileUrl,title}].", ), colorId: googleColorIdInput.describe( "Google Calendar event color id, 1 through 11.", ), workingLocationType: workingLocationTypeInput.describe( "For eventType=workingLocation: homeOffice, officeLocation, or customLocation.", ), workingLocationLabel: z .string() .optional() .describe( "For eventType=workingLocation: label shown in Google Calendar.", ), addGoogleMeet: cliBoolean .optional() .describe("Preselect Google Meet for this draft"), addZoom: cliBoolean.optional().describe("Preselect Zoom for this draft"), attendees: attendeesInput .optional() .describe( "Invitees — either an array of {email, displayName?, optional?} or a comma-separated string of emails. Set optional:true to mark a guest optional.", ), accountEmail: z .string() .optional() .describe("Account email to create the event on"), }), mcpApp: { compactCatalog: true, resource: embedApp({ title: "Review calendar invite", description: "Open the draft in the real Calendar event editor so the user can review attendees, time, location, conferencing, and reminders.", iframeTitle: "Agent-Native Calendar", openLabel: "Open in Calendar", height: 900, }), }, run: async (args) => { const ownerEmail = getRequestUserEmail(); if (!ownerEmail) throw new Error("no authenticated user"); const action = args.action ?? "create"; if (args.addGoogleMeet && args.addZoom) { throw new Error("Choose either Google Meet or Zoom, not both."); } if (action === "delete-all") { const count = await deleteAppStateByPrefix(DRAFT_PREFIX); return `Deleted ${count} calendar event draft(s)`; } if (action === "delete") { if (!args.id) throw new Error("--id is required for delete"); const safeId = sanitizeDraftId(args.id); if (!safeId) throw new Error(`Invalid draft ID "${args.id}"`); const deleted = await deleteAppState(draftKey(safeId)); if (!deleted) throw new Error(`Calendar event draft "${safeId}" not found`); return `Deleted calendar event draft ${safeId}`; } const rawId = args.id || `draft-${Date.now()}`; const id = sanitizeDraftId(rawId) ?? `draft-${Date.now()}`; const existing = (await readAppState( draftKey(id), )) as unknown as CalendarEventDraft | null; if (action === "update" && !existing) { throw new Error(`Calendar event draft "${id}" not found`); } if (action === "create" && existing) failDraftIdTaken(id); const now = new Date().toISOString(); const draft: CalendarEventDraft = { ...(existing ?? { id, createdAt: now }), id, updatedAt: now, }; setIfPresent(draft, "title", args.title); setIfPresent(draft, "description", args.description); setIfPresent(draft, "start", args.start); setIfPresent(draft, "end", args.end); setIfPresent(draft, "startTimeZone", args.startTimeZone); setIfPresent(draft, "endTimeZone", args.endTimeZone); setIfPresent(draft, "location", args.location); setIfPresent(draft, "allDay", args.allDay); setIfPresent(draft, "fullDay", args.fullDay); setIfPresent(draft, "eventType", args.eventType); setIfPresent(draft, "transparency", args.transparency); setIfPresent(draft, "visibility", args.visibility); setIfPresent(draft, "colorId", args.colorId); setIfPresent(draft, "recurrence", normalizeRecurrence(args.recurrence)); setIfPresent(draft, "attachments", args.attachments); setIfPresent(draft, "workingLocationType", args.workingLocationType); setIfPresent(draft, "workingLocationLabel", args.workingLocationLabel); setIfPresent(draft, "addGoogleMeet", args.addGoogleMeet); setIfPresent(draft, "addZoom", args.addZoom); setIfPresent(draft, "accountEmail", args.accountEmail); if (draft.fullDay === true) { if (draft.eventType !== "outOfOffice") { throw new Error("fullDay is only supported for out-of-office drafts."); } if (!draft.start || !draft.end) { throw new Error( "Full-day out-of-office drafts require start and end dates.", ); } normalizeCreateEventInput({ title: draft.title, eventType: draft.eventType, start: draft.start, end: draft.end, startTimeZone: draft.startTimeZone, endTimeZone: draft.endTimeZone, fullDay: true, }); draft.allDay = false; } if (draft.eventType === "outOfOffice") { draft.outOfOfficeProperties = { autoDeclineMode: args.autoDeclineMode ?? draft.outOfOfficeProperties?.autoDeclineMode ?? "declineAllConflictingInvitations", declineMessage: (args.autoDeclineMode ?? draft.outOfOfficeProperties?.autoDeclineMode) === "declineNone" ? undefined : (args.declineMessage ?? draft.outOfOfficeProperties?.declineMessage ?? "Declined because I am out of office"), }; } if (draft.eventType === "outOfOffice" && !draft.title?.trim()) { draft.title = "Out of office"; } const attendees = normalizeAttendees(args.attendees); setIfPresent(draft, "attendees", attendees); const reminderFields = buildReminderOverrides({ reminders: args.reminders, reminderMinutes: args.reminderMinutes, reminderMethod: args.reminderMethod, useDefaultReminders: args.remindersUseDefault, }); Object.assign(draft, reminderFields); const record = draft as unknown as Record; if (action === "create") { // Insert only if the key is still free, so two creates racing on one // ID can't overwrite each other. if (!(await compareAndSetAppState(draftKey(id), null, record))) { failDraftIdTaken(id); } } else { await writeAppState(draftKey(id), record); } return { id, draft, deepLink: eventDraftDeepLink(draft), message: `Saved calendar event draft ${id}`, }; }, link: ({ result }) => { if (!result || typeof result !== "object") return null; const draft = (result as { draft?: CalendarEventDraft }).draft; const id = (result as { id?: string }).id; if (!draft || !id) return null; return { url: eventDraftDeepLink(draft), label: "Review invite in Calendar", view: "calendar", }; }, });