From 5cd2fe5481e595815278b72aa99e03ebbb6bd447 Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:05:50 +0530 Subject: [PATCH 1/8] feat(cal-com): add AI actions and output schemas (#15816) --- .../pieces/community/cal-com/package.json | 2 +- .../pieces/community/cal-com/src/index.ts | 14 +- .../cal-com/src/lib/actions/add-attendee.ts | 60 +++++ .../cal-com/src/lib/actions/cancel-booking.ts | 44 ++++ .../src/lib/actions/confirm-booking.ts | 38 +++ .../cal-com/src/lib/actions/create-booking.ts | 93 +++++++ .../src/lib/actions/create-event-type.ts | 83 +++++++ .../src/lib/actions/create-schedule.ts | 65 +++++ .../src/lib/actions/decline-booking.ts | 44 ++++ .../src/lib/actions/delete-event-type.ts | 38 +++ .../src/lib/actions/delete-schedule.ts | 35 +++ .../src/lib/actions/get-available-slots.ts | 65 +++++ .../cal-com/src/lib/actions/get-booking.ts | 38 +++ .../src/lib/actions/get-default-schedule.ts | 31 +++ .../cal-com/src/lib/actions/get-event-type.ts | 38 +++ .../cal-com/src/lib/actions/get-my-profile.ts | 30 +++ .../cal-com/src/lib/actions/get-schedule.ts | 38 +++ .../cal-com/src/lib/actions/index.ts | 62 +++++ .../cal-com/src/lib/actions/list-bookings.ts | 111 +++++++++ .../src/lib/actions/list-event-types.ts | 53 ++++ .../cal-com/src/lib/actions/list-schedules.ts | 31 +++ .../cal-com/src/lib/actions/list-teams.ts | 28 +++ .../cal-com/src/lib/actions/list-timezones.ts | 44 ++++ .../src/lib/actions/mark-booking-no-show.ts | 81 ++++++ .../src/lib/actions/reschedule-booking.ts | 50 ++++ .../src/lib/actions/update-event-type.ts | 100 ++++++++ .../src/lib/actions/update-my-profile.ts | 93 +++++++ .../src/lib/actions/update-schedule.ts | 80 ++++++ .../pieces/community/cal-com/src/lib/auth.ts | 7 + .../cal-com/src/lib/common/client.ts | 122 +++++++++ .../src/lib/{common.ts => common/index.ts} | 2 + .../cal-com/src/lib/output-schemas.ts | 233 ++++++++++++++++++ .../cal-com/src/lib/triggers/index.ts | 2 + .../src/lib/triggers/register-webhook.ts | 6 +- 34 files changed, 1851 insertions(+), 10 deletions(-) create mode 100644 packages/pieces/community/cal-com/src/lib/actions/add-attendee.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/cancel-booking.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/confirm-booking.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/create-booking.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/create-event-type.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/create-schedule.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/decline-booking.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/delete-event-type.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/delete-schedule.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/get-available-slots.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/get-booking.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/get-default-schedule.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/get-event-type.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/get-my-profile.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/get-schedule.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/index.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/list-bookings.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/list-event-types.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/list-schedules.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/list-teams.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/list-timezones.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/mark-booking-no-show.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/reschedule-booking.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/update-event-type.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/update-my-profile.ts create mode 100644 packages/pieces/community/cal-com/src/lib/actions/update-schedule.ts create mode 100644 packages/pieces/community/cal-com/src/lib/auth.ts create mode 100644 packages/pieces/community/cal-com/src/lib/common/client.ts rename packages/pieces/community/cal-com/src/lib/{common.ts => common/index.ts} (85%) create mode 100644 packages/pieces/community/cal-com/src/lib/output-schemas.ts diff --git a/packages/pieces/community/cal-com/package.json b/packages/pieces/community/cal-com/package.json index 2ec1c03009ae..0bbc3ad91013 100644 --- a/packages/pieces/community/cal-com/package.json +++ b/packages/pieces/community/cal-com/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-cal-com", - "version": "0.4.9", + "version": "0.5.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/cal-com/src/index.ts b/packages/pieces/community/cal-com/src/index.ts index d885219d92a7..cb7f901641a4 100644 --- a/packages/pieces/community/cal-com/src/index.ts +++ b/packages/pieces/community/cal-com/src/index.ts @@ -1,21 +1,19 @@ -import { PieceAuth, createPiece } from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; +import { calcomAuth } from './lib/auth'; +import { actions } from './lib/actions'; import { triggers } from './lib/triggers'; -export const calcomAuth = PieceAuth.SecretText({ - displayName: 'API Key', - description: 'API Key provided by cal.com', - required: true, -}); +export { calcomAuth }; export const calcom = createPiece({ displayName: 'Cal.com', description: 'Open-source alternative to Calendly', - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/cal.com.png', categories: [PieceCategory.PRODUCTIVITY], authors: ["kishanprmr","AbdulTheActivePiecer","khaledmashaly","abuaboud"], auth: calcomAuth, - actions: [], + actions, triggers, }); diff --git a/packages/pieces/community/cal-com/src/lib/actions/add-attendee.ts b/packages/pieces/community/cal-com/src/lib/actions/add-attendee.ts new file mode 100644 index 000000000000..70b1e332b63c --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/add-attendee.ts @@ -0,0 +1,60 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { addAttendeeActionOutputSchema } from '../output-schemas'; + +export const calcomAddAttendee = createAction({ + auth: calcomAuth, + name: 'calcom_add_attendee', + classification: 'WRITE', + displayName: 'Add Attendee to Booking', + description: 'Add an additional attendee to an existing Cal.com booking.', + audience: 'ai', + outputSchema: addAttendeeActionOutputSchema, + aiMetadata: { + description: + 'Adds one attendee to an existing booking. Get the booking uid from List Bookings or Get Booking. Not idempotent: each call adds another attendee, so retries duplicate.', + idempotent: false, + }, + props: { + booking_uid: Property.ShortText({ + displayName: 'Booking UID', + required: true, + }), + name: Property.ShortText({ + displayName: 'Attendee Name', + required: true, + }), + email: Property.ShortText({ + displayName: 'Attendee Email', + required: true, + }), + time_zone: Property.ShortText({ + displayName: 'Attendee Timezone', + description: 'IANA timezone, e.g. Europe/Berlin. Get valid values from List Timezones.', + required: true, + }), + phone_number: Property.ShortText({ + displayName: 'Attendee Phone Number', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { booking_uid, name, email, time_zone, phone_number } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: `/bookings/${booking_uid}/attendees`, + version: calcomCommon.versions.attendees, + body: { + name, + email, + timeZone: time_zone, + ...(phone_number !== undefined ? { phoneNumber: phone_number } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/cancel-booking.ts b/packages/pieces/community/cal-com/src/lib/actions/cancel-booking.ts new file mode 100644 index 000000000000..ee1a8ae465e9 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/cancel-booking.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { bookingActionOutputSchema } from '../output-schemas'; + +export const calcomCancelBooking = createAction({ + auth: calcomAuth, + name: 'calcom_cancel_booking', + classification: 'DESTRUCTIVE', + displayName: 'Cancel Booking', + description: 'Cancel an active Cal.com booking by its uid.', + audience: 'ai', + outputSchema: bookingActionOutputSchema, + aiMetadata: { + description: + 'Cancels an active booking by uid, notifying the attendee. Get the uid from List Bookings or Get Booking. Not idempotent: a repeat call on an already-cancelled booking errors.', + idempotent: false, + }, + props: { + booking_uid: Property.ShortText({ + displayName: 'Booking UID', + required: true, + }), + cancellation_reason: Property.LongText({ + displayName: 'Cancellation Reason', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { booking_uid, cancellation_reason } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: `/bookings/${booking_uid}/cancel`, + version: calcomCommon.versions.bookingsItem, + body: { + ...(cancellation_reason !== undefined ? { cancellationReason: cancellation_reason } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/confirm-booking.ts b/packages/pieces/community/cal-com/src/lib/actions/confirm-booking.ts new file mode 100644 index 000000000000..5bf3f1dd6375 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/confirm-booking.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { bookingActionOutputSchema } from '../output-schemas'; + +export const calcomConfirmBooking = createAction({ + auth: calcomAuth, + name: 'calcom_confirm_booking', + classification: 'WRITE', + displayName: 'Confirm Booking', + description: 'Confirm a Cal.com booking that is pending host confirmation.', + audience: 'ai', + outputSchema: bookingActionOutputSchema, + aiMetadata: { + description: + 'Confirms a pending booking, finalizing it. Only applies to bookings whose event type requires manual confirmation. Idempotent: confirming an already-confirmed booking is a no-op.', + idempotent: true, + }, + props: { + booking_uid: Property.ShortText({ + displayName: 'Booking UID', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { booking_uid } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: `/bookings/${booking_uid}/confirm`, + version: calcomCommon.versions.bookingsItem, + body: {}, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/create-booking.ts b/packages/pieces/community/cal-com/src/lib/actions/create-booking.ts new file mode 100644 index 000000000000..34f197093983 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/create-booking.ts @@ -0,0 +1,93 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { bookingActionOutputSchema } from '../output-schemas'; + +export const calcomCreateBooking = createAction({ + auth: calcomAuth, + name: 'calcom_create_booking', + classification: 'WRITE', + displayName: 'Create Booking', + description: 'Book a Cal.com event type at a specific start time for one attendee.', + audience: 'ai', + outputSchema: bookingActionOutputSchema, + aiMetadata: { + description: + 'Creates a standard (non-recurring, non-instant) booking for an event type at an exact start time. Use Get Available Slots first to find a valid start time. The booking status is ACCEPTED, or PENDING if the event type requires host confirmation (use Confirm Booking / Decline Booking to resolve it). Not idempotent: each call creates a new booking, so retries duplicate.', + idempotent: false, + }, + props: { + event_type_id: Property.Number({ + displayName: 'Event Type ID', + description: 'Get this from List Event Types.', + required: true, + }), + start: Property.ShortText({ + displayName: 'Start Time', + description: 'ISO 8601 timestamp in UTC, e.g. 2026-03-05T15:00:00Z. Get a valid value from Get Available Slots.', + required: true, + }), + attendee_name: Property.ShortText({ + displayName: 'Attendee Name', + required: true, + }), + attendee_time_zone: Property.ShortText({ + displayName: 'Attendee Timezone', + description: 'IANA timezone, e.g. Europe/Berlin. Get valid values from List Timezones.', + required: true, + }), + attendee_email: Property.ShortText({ + displayName: 'Attendee Email', + required: false, + }), + attendee_phone_number: Property.ShortText({ + displayName: 'Attendee Phone Number', + description: 'International format, e.g. +14155552671.', + required: false, + }), + guests: Property.Array({ + displayName: 'Guest Emails', + required: false, + }), + length_in_minutes: Property.Number({ + displayName: 'Length (Minutes)', + description: 'Only for event types offering multiple possible durations.', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { + event_type_id, + start, + attendee_name, + attendee_time_zone, + attendee_email, + attendee_phone_number, + guests, + length_in_minutes, + } = propsValue; + + const body: Record = { + eventTypeId: event_type_id, + start, + attendee: { + name: attendee_name, + timeZone: attendee_time_zone, + ...(attendee_email !== undefined ? { email: attendee_email } : {}), + ...(attendee_phone_number !== undefined ? { phoneNumber: attendee_phone_number } : {}), + }, + }; + if (guests !== undefined) body['guests'] = guests; + if (length_in_minutes !== undefined) body['lengthInMinutes'] = length_in_minutes; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: '/bookings', + version: calcomCommon.versions.bookingsItem, + body, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/create-event-type.ts b/packages/pieces/community/cal-com/src/lib/actions/create-event-type.ts new file mode 100644 index 000000000000..379b5ff903f0 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/create-event-type.ts @@ -0,0 +1,83 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { eventTypeActionOutputSchema } from '../output-schemas'; + +export const calcomCreateEventType = createAction({ + auth: calcomAuth, + name: 'calcom_create_event_type', + classification: 'WRITE', + displayName: 'Create Event Type', + description: 'Create a new Cal.com event type.', + audience: 'ai', + outputSchema: eventTypeActionOutputSchema, + aiMetadata: { + description: + 'Creates a new bookable event type with a title, slug and length. Use Additional Fields for any of the many advanced Cal.com event-type settings not exposed directly here (e.g. booking limits, recurrence, locations) — it is merged into the same request. Not idempotent: each call creates a new event type, so retries duplicate.', + idempotent: false, + }, + props: { + title: Property.ShortText({ + displayName: 'Title', + required: true, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'URL-safe identifier for the event type, must be unique.', + required: true, + }), + length_in_minutes: Property.Number({ + displayName: 'Length (Minutes)', + required: true, + }), + description: Property.LongText({ + displayName: 'Description', + required: false, + }), + schedule_id: Property.Number({ + displayName: 'Schedule ID', + description: 'Assign a different availability schedule than the owner\'s default one. Get this from List Schedules.', + required: false, + }), + hidden: Property.Checkbox({ + displayName: 'Hidden', + required: false, + }), + additional_fields: Property.Json({ + displayName: 'Additional Fields', + description: 'Advanced event-type fields not listed above, as a JSON object matching the Cal.com v2 Create Event Type request body (e.g. {"bookingLimitsCount": {...}}).', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { + title, + slug, + length_in_minutes, + description, + schedule_id, + hidden, + additional_fields, + } = propsValue; + + const body: Record = { + ...(additional_fields as Record | undefined), + title, + slug, + lengthInMinutes: length_in_minutes, + }; + if (description !== undefined) body['description'] = description; + if (schedule_id !== undefined) body['scheduleId'] = schedule_id; + if (hidden !== undefined) body['hidden'] = hidden; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: '/event-types', + version: calcomCommon.versions.eventTypes, + body, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/create-schedule.ts b/packages/pieces/community/cal-com/src/lib/actions/create-schedule.ts new file mode 100644 index 000000000000..2e3d10ac4e58 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/create-schedule.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { scheduleActionOutputSchema } from '../output-schemas'; + +export const calcomCreateSchedule = createAction({ + auth: calcomAuth, + name: 'calcom_create_schedule', + classification: 'WRITE', + displayName: 'Create Schedule', + description: 'Create a new availability schedule.', + audience: 'ai', + outputSchema: scheduleActionOutputSchema, + aiMetadata: { + description: + 'Creates a new availability schedule with a weekly recurring pattern and optional date overrides. Setting Is Default to Yes replaces the user\'s current default schedule. Not idempotent: each call creates a new schedule, so retries duplicate.', + idempotent: false, + }, + props: { + name: Property.ShortText({ + displayName: 'Name', + required: true, + }), + time_zone: Property.ShortText({ + displayName: 'Timezone', + description: 'IANA timezone, e.g. Europe/Berlin. Get valid values from List Timezones.', + required: true, + }), + is_default: Property.Checkbox({ + displayName: 'Is Default', + description: 'Each user has exactly one default schedule; setting this replaces it.', + required: true, + }), + availability: Property.Json({ + displayName: 'Availability', + description: + 'Array of weekly availability blocks, e.g. [{"days": ["Monday", "Tuesday"], "startTime": "09:00", "endTime": "17:00"}]. Leave empty for Cal.com\'s default Mon-Fri 9-17 pattern.', + required: false, + }), + overrides: Property.Json({ + displayName: 'Date Overrides', + description: 'Array of date-specific overrides, e.g. [{"date": "2026-05-20", "startTime": "12:00", "endTime": "13:00"}].', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { name, time_zone, is_default, availability, overrides } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: '/schedules', + version: calcomCommon.versions.schedules, + body: { + name, + timeZone: time_zone, + isDefault: is_default, + ...(availability !== undefined ? { availability } : {}), + ...(overrides !== undefined ? { overrides } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/decline-booking.ts b/packages/pieces/community/cal-com/src/lib/actions/decline-booking.ts new file mode 100644 index 000000000000..82f6e7fa0f56 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/decline-booking.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { bookingActionOutputSchema } from '../output-schemas'; + +export const calcomDeclineBooking = createAction({ + auth: calcomAuth, + name: 'calcom_decline_booking', + classification: 'DESTRUCTIVE', + displayName: 'Decline Booking', + description: 'Decline a Cal.com booking that is pending host confirmation.', + audience: 'ai', + outputSchema: bookingActionOutputSchema, + aiMetadata: { + description: + 'Declines a pending booking, notifying the attendee it was not accepted. Only applies to bookings awaiting confirmation. Not idempotent: a repeat call on an already-declined booking errors.', + idempotent: false, + }, + props: { + booking_uid: Property.ShortText({ + displayName: 'Booking UID', + required: true, + }), + reason: Property.LongText({ + displayName: 'Reason', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { booking_uid, reason } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: `/bookings/${booking_uid}/decline`, + version: calcomCommon.versions.bookingsItem, + body: { + ...(reason !== undefined ? { reason } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/delete-event-type.ts b/packages/pieces/community/cal-com/src/lib/actions/delete-event-type.ts new file mode 100644 index 000000000000..120e8601e0d0 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/delete-event-type.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { deleteEventTypeActionOutputSchema } from '../output-schemas'; + +export const calcomDeleteEventType = createAction({ + auth: calcomAuth, + name: 'calcom_delete_event_type', + classification: 'DESTRUCTIVE', + displayName: 'Delete Event Type', + description: 'Permanently delete a Cal.com event type by id.', + audience: 'ai', + outputSchema: deleteEventTypeActionOutputSchema, + aiMetadata: { + description: + 'Permanently deletes an event type by id. Existing bookings for it are unaffected, but no new bookings can be made against it afterward. Not idempotent: a repeat call on the same id fails because it is already gone.', + idempotent: false, + }, + props: { + event_type_id: Property.Number({ + displayName: 'Event Type ID', + description: 'Get this from List Event Types.', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { event_type_id } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.DELETE, + path: `/event-types/${event_type_id}`, + version: calcomCommon.versions.eventTypes, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/delete-schedule.ts b/packages/pieces/community/cal-com/src/lib/actions/delete-schedule.ts new file mode 100644 index 000000000000..173474d37540 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/delete-schedule.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; + +export const calcomDeleteSchedule = createAction({ + auth: calcomAuth, + name: 'calcom_delete_schedule', + classification: 'DESTRUCTIVE', + displayName: 'Delete Schedule', + description: 'Permanently delete an availability schedule by id.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes an availability schedule by id. Get the id from List Schedules. Not idempotent: a repeat call on the same id fails because it is already gone.', + idempotent: false, + }, + props: { + schedule_id: Property.Number({ + displayName: 'Schedule ID', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { schedule_id } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.DELETE, + path: `/schedules/${schedule_id}`, + version: calcomCommon.versions.schedules, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/get-available-slots.ts b/packages/pieces/community/cal-com/src/lib/actions/get-available-slots.ts new file mode 100644 index 000000000000..7d8055c34428 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/get-available-slots.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { availableSlotsActionOutputSchema } from '../output-schemas'; + +export const calcomGetAvailableSlots = createAction({ + auth: calcomAuth, + name: 'calcom_get_available_slots', + classification: 'SEARCH', + displayName: 'Get Available Slots', + description: 'Find bookable time slots for a Cal.com event type within a time range.', + audience: 'ai', + outputSchema: availableSlotsActionOutputSchema, + aiMetadata: { + description: + 'Returns available start times for an event type between Start and End, accounting for existing bookings and availability. Use before Create Booking to pick a valid start time.', + idempotent: true, + }, + props: { + event_type_id: Property.Number({ + displayName: 'Event Type ID', + description: 'Get this from List Event Types.', + required: true, + }), + start: Property.ShortText({ + displayName: 'Start', + description: 'ISO 8601 timestamp for the beginning of the search range.', + required: true, + }), + end: Property.ShortText({ + displayName: 'End', + description: 'ISO 8601 timestamp for the end of the search range.', + required: true, + }), + time_zone: Property.ShortText({ + displayName: 'Timezone', + description: 'IANA timezone the returned slots are expressed in.', + required: false, + }), + duration: Property.Number({ + displayName: 'Duration (Minutes)', + description: 'Only for event types offering multiple possible durations.', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { event_type_id, start, end, time_zone, duration } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: '/slots', + version: calcomCommon.versions.slots, + query: { + eventTypeId: event_type_id, + start, + end, + timeZone: time_zone, + duration, + }, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/get-booking.ts b/packages/pieces/community/cal-com/src/lib/actions/get-booking.ts new file mode 100644 index 000000000000..3846cd2aebff --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/get-booking.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { bookingActionOutputSchema } from '../output-schemas'; + +export const calcomGetBooking = createAction({ + auth: calcomAuth, + name: 'calcom_get_booking', + classification: 'READ', + displayName: 'Get Booking', + description: 'Get the full details of a single Cal.com booking by its uid, including its attendees.', + audience: 'ai', + outputSchema: bookingActionOutputSchema, + aiMetadata: { + description: + 'Retrieves full details for one booking by uid, including its attendees. Get the uid from List Bookings or from a booking trigger payload.', + idempotent: true, + }, + props: { + booking_uid: Property.ShortText({ + displayName: 'Booking UID', + description: 'Get this from List Bookings.', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { booking_uid } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: `/bookings/${booking_uid}`, + version: calcomCommon.versions.bookingsItem, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/get-default-schedule.ts b/packages/pieces/community/cal-com/src/lib/actions/get-default-schedule.ts new file mode 100644 index 000000000000..423310a3fc1f --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/get-default-schedule.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { scheduleActionOutputSchema } from '../output-schemas'; + +export const calcomGetDefaultSchedule = createAction({ + auth: calcomAuth, + name: 'calcom_get_default_schedule', + classification: 'READ', + displayName: 'Get Default Schedule', + description: 'Get the connected user\'s default availability schedule.', + audience: 'ai', + outputSchema: scheduleActionOutputSchema, + aiMetadata: { + description: + 'Retrieves the authenticated user\'s own default availability schedule directly, without needing its id. Prefer List Schedules when you need every schedule, not just the default.', + idempotent: true, + }, + props: {}, + async run(context) { + const { auth } = context; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: '/schedules/default', + version: calcomCommon.versions.schedules, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/get-event-type.ts b/packages/pieces/community/cal-com/src/lib/actions/get-event-type.ts new file mode 100644 index 000000000000..02ee67621eae --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/get-event-type.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { eventTypeActionOutputSchema } from '../output-schemas'; + +export const calcomGetEventType = createAction({ + auth: calcomAuth, + name: 'calcom_get_event_type', + classification: 'READ', + displayName: 'Get Event Type', + description: 'Get the full configuration of a single Cal.com event type by id.', + audience: 'ai', + outputSchema: eventTypeActionOutputSchema, + aiMetadata: { + description: + 'Retrieves full details for one event type by id, including duration, booking window and location settings. Get the id from List Event Types. Does not include the event type\'s bookings.', + idempotent: true, + }, + props: { + event_type_id: Property.Number({ + displayName: 'Event Type ID', + description: 'Get this from List Event Types.', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { event_type_id } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: `/event-types/${event_type_id}`, + version: calcomCommon.versions.eventTypes, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/get-my-profile.ts b/packages/pieces/community/cal-com/src/lib/actions/get-my-profile.ts new file mode 100644 index 000000000000..a4c446fe5742 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/get-my-profile.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { profileActionOutputSchema } from '../output-schemas'; + +export const calcomGetMyProfile = createAction({ + auth: calcomAuth, + name: 'calcom_get_my_profile', + classification: 'READ', + displayName: 'Get My Profile', + description: 'Get the profile of the connected Cal.com user.', + audience: 'ai', + outputSchema: profileActionOutputSchema, + aiMetadata: { + description: + 'Retrieves the authenticated user\'s own profile: id, name, email, username and default timezone. Common prerequisite for actions that need the current user\'s id or timezone.', + idempotent: true, + }, + props: {}, + async run(context) { + const { auth } = context; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: '/me', + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/get-schedule.ts b/packages/pieces/community/cal-com/src/lib/actions/get-schedule.ts new file mode 100644 index 000000000000..51fded17b98b --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/get-schedule.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { scheduleActionOutputSchema } from '../output-schemas'; + +export const calcomGetSchedule = createAction({ + auth: calcomAuth, + name: 'calcom_get_schedule', + classification: 'READ', + displayName: 'Get Schedule', + description: 'Get the full details of one availability schedule by id.', + audience: 'ai', + outputSchema: scheduleActionOutputSchema, + aiMetadata: { + description: + 'Retrieves full details for one availability schedule by id, including its weekly availability blocks and date overrides. Get the id from List Schedules.', + idempotent: true, + }, + props: { + schedule_id: Property.Number({ + displayName: 'Schedule ID', + description: 'Get this from List Schedules.', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { schedule_id } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: `/schedules/${schedule_id}`, + version: calcomCommon.versions.schedules, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/index.ts b/packages/pieces/community/cal-com/src/lib/actions/index.ts new file mode 100644 index 000000000000..5569fc053755 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/index.ts @@ -0,0 +1,62 @@ +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomListEventTypes } from './list-event-types'; +import { calcomGetEventType } from './get-event-type'; +import { calcomCreateEventType } from './create-event-type'; +import { calcomUpdateEventType } from './update-event-type'; +import { calcomDeleteEventType } from './delete-event-type'; +import { calcomListBookings } from './list-bookings'; +import { calcomGetBooking } from './get-booking'; +import { calcomCreateBooking } from './create-booking'; +import { calcomCancelBooking } from './cancel-booking'; +import { calcomRescheduleBooking } from './reschedule-booking'; +import { calcomConfirmBooking } from './confirm-booking'; +import { calcomDeclineBooking } from './decline-booking'; +import { calcomMarkBookingNoShow } from './mark-booking-no-show'; +import { calcomAddAttendee } from './add-attendee'; +import { calcomGetAvailableSlots } from './get-available-slots'; +import { calcomListSchedules } from './list-schedules'; +import { calcomGetSchedule } from './get-schedule'; +import { calcomCreateSchedule } from './create-schedule'; +import { calcomUpdateSchedule } from './update-schedule'; +import { calcomDeleteSchedule } from './delete-schedule'; +import { calcomGetDefaultSchedule } from './get-default-schedule'; +import { calcomListTimezones } from './list-timezones'; +import { calcomGetMyProfile } from './get-my-profile'; +import { calcomUpdateMyProfile } from './update-my-profile'; +import { calcomListTeams } from './list-teams'; + +export const actions = [ + calcomListEventTypes, + calcomGetEventType, + calcomCreateEventType, + calcomUpdateEventType, + calcomDeleteEventType, + calcomListBookings, + calcomGetBooking, + calcomCreateBooking, + calcomCancelBooking, + calcomRescheduleBooking, + calcomConfirmBooking, + calcomDeclineBooking, + calcomMarkBookingNoShow, + calcomAddAttendee, + calcomGetAvailableSlots, + calcomListSchedules, + calcomGetSchedule, + calcomCreateSchedule, + calcomUpdateSchedule, + calcomDeleteSchedule, + calcomGetDefaultSchedule, + calcomListTimezones, + calcomGetMyProfile, + calcomUpdateMyProfile, + calcomListTeams, + createCustomApiCallAction({ + baseUrl: () => 'https://api.cal.com/v2', + auth: calcomAuth, + authMapping: async (auth) => ({ + Authorization: `Bearer ${auth.secret_text}`, + }), + }), +]; diff --git a/packages/pieces/community/cal-com/src/lib/actions/list-bookings.ts b/packages/pieces/community/cal-com/src/lib/actions/list-bookings.ts new file mode 100644 index 000000000000..4329f3ecc846 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/list-bookings.ts @@ -0,0 +1,111 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { listBookingsActionOutputSchema } from '../output-schemas'; + +const MAX_PAGE_SIZE = 100; + +export const calcomListBookings = createAction({ + auth: calcomAuth, + name: 'calcom_list_bookings', + classification: 'SEARCH', + displayName: 'List Bookings', + description: 'Search Cal.com bookings by status, attendee, event type or date range.', + audience: 'ai', + outputSchema: listBookingsActionOutputSchema, + aiMetadata: { + description: + 'Lists bookings with optional filters, including a team ID from List Teams. Returns { bookings, nextCursor, hasMore } — pass nextCursor as Cursor on the next call when hasMore is true. Use Get Booking for the full detail of a single result.', + idempotent: true, + }, + props: { + status: Property.StaticDropdown({ + displayName: 'Status', + required: false, + options: { + options: [ + { label: 'Upcoming', value: 'upcoming' }, + { label: 'Recurring', value: 'recurring' }, + { label: 'Past', value: 'past' }, + { label: 'Cancelled', value: 'cancelled' }, + { label: 'Unconfirmed', value: 'unconfirmed' }, + ], + }, + }), + attendee_email: Property.ShortText({ + displayName: 'Attendee Email', + required: false, + }), + attendee_name: Property.ShortText({ + displayName: 'Attendee Name', + required: false, + }), + event_type_id: Property.Number({ + displayName: 'Event Type ID', + required: false, + }), + team_id: Property.Number({ + displayName: 'Team ID', + description: 'Get this from List Teams.', + required: false, + }), + after_start: Property.ShortText({ + displayName: 'Starting After', + description: 'ISO 8601 timestamp. Only bookings starting after this time.', + required: false, + }), + before_end: Property.ShortText({ + displayName: 'Ending Before', + description: 'ISO 8601 timestamp. Only bookings ending before this time.', + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: `1-${MAX_PAGE_SIZE}, default 50.`, + required: false, + }), + cursor: Property.ShortText({ + displayName: 'Cursor', + description: 'Pass the cursor from a previous call to get the next page.', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { + status, + attendee_email, + attendee_name, + event_type_id, + team_id, + after_start, + before_end, + limit, + cursor, + } = propsValue; + + const clampedLimit = + limit === undefined ? undefined : Math.min(Math.max(limit, 1), MAX_PAGE_SIZE); + + const { data, nextCursor, hasMore } = await calcomCommon.calRequestPaginated({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: '/bookings', + version: calcomCommon.versions.bookingsList, + query: { + status, + attendeeEmail: attendee_email, + attendeeName: attendee_name, + eventTypeId: event_type_id, + teamId: team_id, + afterStart: after_start, + beforeEnd: before_end, + limit: clampedLimit, + cursor, + }, + }); + + return { bookings: data, nextCursor, hasMore }; + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/list-event-types.ts b/packages/pieces/community/cal-com/src/lib/actions/list-event-types.ts new file mode 100644 index 000000000000..e4aecbaab1b9 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/list-event-types.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { listEventTypesActionOutputSchema } from '../output-schemas'; + +export const calcomListEventTypes = createAction({ + auth: calcomAuth, + name: 'calcom_list_event_types', + classification: 'SEARCH', + displayName: 'List Event Types', + description: 'List Cal.com event types, optionally filtered by owner.', + audience: 'ai', + outputSchema: listEventTypesActionOutputSchema, + aiMetadata: { + description: + 'Lists event types. With no filters, returns the event types owned by the connected API key. Use this to resolve an event type id before creating or updating a booking or event type.', + idempotent: true, + }, + props: { + username: Property.ShortText({ + displayName: 'Username', + description: 'Only event types owned by this Cal.com username. Leave empty to list your own.', + required: false, + }), + event_slug: Property.ShortText({ + displayName: 'Event Slug', + description: 'Filter to the event type with this exact slug. Requires Username to be set too.', + required: false, + }), + org_slug: Property.ShortText({ + displayName: 'Organization Slug', + description: 'Only used together with Username / Event Slug when booking through an organization.', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { username, event_slug, org_slug } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: '/event-types', + version: calcomCommon.versions.eventTypes, + query: { + username, + eventSlug: event_slug, + orgSlug: org_slug, + }, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/list-schedules.ts b/packages/pieces/community/cal-com/src/lib/actions/list-schedules.ts new file mode 100644 index 000000000000..8ef1926eaaf8 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/list-schedules.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { listSchedulesActionOutputSchema } from '../output-schemas'; + +export const calcomListSchedules = createAction({ + auth: calcomAuth, + name: 'calcom_list_schedules', + classification: 'SEARCH', + displayName: 'List Schedules', + description: 'List all of the connected user\'s availability schedules.', + audience: 'ai', + outputSchema: listSchedulesActionOutputSchema, + aiMetadata: { + description: + 'Lists the authenticated user\'s availability schedules, including which one is marked default. Use to resolve a schedule id before Get Schedule, Update Schedule or Delete Schedule.', + idempotent: true, + }, + props: {}, + async run(context) { + const { auth } = context; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: '/schedules', + version: calcomCommon.versions.schedules, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/list-teams.ts b/packages/pieces/community/cal-com/src/lib/actions/list-teams.ts new file mode 100644 index 000000000000..c7e671de66b5 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/list-teams.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; + +export const calcomListTeams = createAction({ + auth: calcomAuth, + name: 'calcom_list_teams', + classification: 'SEARCH', + displayName: 'List Teams', + description: 'List the teams the connected Cal.com user belongs to.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the authenticated user\'s teams, with their ids. Use to resolve a team id for filtering List Bookings by team.', + idempotent: true, + }, + props: {}, + async run(context) { + const { auth } = context; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: '/teams', + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/list-timezones.ts b/packages/pieces/community/cal-com/src/lib/actions/list-timezones.ts new file mode 100644 index 000000000000..21423da10cf6 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/list-timezones.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { listTimezonesActionOutputSchema } from '../output-schemas'; + +export const calcomListTimezones = createAction({ + auth: calcomAuth, + name: 'calcom_list_timezones', + classification: 'SEARCH', + displayName: 'List Timezones', + description: 'List valid IANA timezone strings Cal.com accepts.', + audience: 'ai', + outputSchema: listTimezonesActionOutputSchema, + aiMetadata: { + description: + 'Lists valid IANA timezone identifiers (e.g. Europe/Berlin), optionally filtered by a search term. Use to find a valid value for actions that take a timezone, such as Create Booking or Create Schedule.', + idempotent: true, + }, + props: { + search: Property.ShortText({ + displayName: 'Search', + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { search, limit } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: '/timezones', + query: { + search, + limit, + }, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/mark-booking-no-show.ts b/packages/pieces/community/cal-com/src/lib/actions/mark-booking-no-show.ts new file mode 100644 index 000000000000..0ffa4b3fa5c6 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/mark-booking-no-show.ts @@ -0,0 +1,81 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { bookingActionOutputSchema } from '../output-schemas'; + +function toOptionalBoolean(value: string | undefined): boolean | undefined { + return value === undefined ? undefined : value === 'true'; +} + +const YES_NO_OPTIONS = { + options: [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ], +}; + +export const calcomMarkBookingNoShow = createAction({ + auth: calcomAuth, + name: 'calcom_mark_booking_no_show', + classification: 'WRITE', + displayName: 'Mark Booking No-Show', + description: 'Mark the host and/or a specific attendee absent for a Cal.com booking.', + audience: 'ai', + outputSchema: bookingActionOutputSchema, + aiMetadata: { + description: + 'Flags whether the host and/or one attendee (by email) were absent for a booking. At least one of Host Absent or Attendee Email must be set. Idempotent: re-applying the same absent flag is a no-op.', + idempotent: true, + }, + props: { + booking_uid: Property.ShortText({ + displayName: 'Booking UID', + required: true, + }), + host_absent: Property.StaticDropdown({ + displayName: 'Host Absent', + required: false, + options: YES_NO_OPTIONS, + }), + attendee_email: Property.ShortText({ + displayName: 'Attendee Email', + description: 'Leave empty to only set Host Absent.', + required: false, + }), + attendee_absent: Property.StaticDropdown({ + displayName: 'Attendee Absent', + description: 'Only used together with Attendee Email. Defaults to Yes.', + required: false, + options: YES_NO_OPTIONS, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { booking_uid, host_absent, attendee_email, attendee_absent } = propsValue; + + const hostValue = toOptionalBoolean(host_absent); + if (hostValue === undefined && attendee_email === undefined) { + throw new Error('Set at least one of Host Absent or Attendee Email.'); + } + + const body: Record = {}; + if (hostValue !== undefined) body['host'] = hostValue; + if (attendee_email !== undefined) { + body['attendees'] = [ + { + email: attendee_email, + absent: toOptionalBoolean(attendee_absent) ?? true, + }, + ]; + } + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: `/bookings/${booking_uid}/mark-absent`, + version: calcomCommon.versions.bookingsItem, + body, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/reschedule-booking.ts b/packages/pieces/community/cal-com/src/lib/actions/reschedule-booking.ts new file mode 100644 index 000000000000..b0574952553d --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/reschedule-booking.ts @@ -0,0 +1,50 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { bookingActionOutputSchema } from '../output-schemas'; + +export const calcomRescheduleBooking = createAction({ + auth: calcomAuth, + name: 'calcom_reschedule_booking', + classification: 'WRITE', + displayName: 'Reschedule Booking', + description: 'Move a Cal.com booking to a new start time.', + audience: 'ai', + outputSchema: bookingActionOutputSchema, + aiMetadata: { + description: + 'Reschedules a booking to a new start time; the vendor issues a new booking uid for the moved booking, returned in the response. Use Get Available Slots to find a valid new time. Not idempotent: repeating the same call reschedules again.', + idempotent: false, + }, + props: { + booking_uid: Property.ShortText({ + displayName: 'Booking UID', + required: true, + }), + start: Property.ShortText({ + displayName: 'New Start Time', + description: 'ISO 8601 timestamp.', + required: true, + }), + rescheduling_reason: Property.LongText({ + displayName: 'Reason', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { booking_uid, start, rescheduling_reason } = propsValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: `/bookings/${booking_uid}/reschedule`, + version: calcomCommon.versions.bookingsItem, + body: { + start, + ...(rescheduling_reason !== undefined ? { reschedulingReason: rescheduling_reason } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/update-event-type.ts b/packages/pieces/community/cal-com/src/lib/actions/update-event-type.ts new file mode 100644 index 000000000000..4b5c16c743e9 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/update-event-type.ts @@ -0,0 +1,100 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { eventTypeActionOutputSchema } from '../output-schemas'; + +function toOptionalBoolean(value: string | undefined): boolean | undefined { + return value === undefined ? undefined : value === 'true'; +} + +export const calcomUpdateEventType = createAction({ + auth: calcomAuth, + name: 'calcom_update_event_type', + classification: 'WRITE', + displayName: 'Update Event Type', + description: 'Update an existing Cal.com event type. Only the fields you set are changed.', + audience: 'ai', + outputSchema: eventTypeActionOutputSchema, + aiMetadata: { + description: + 'Partially updates an event type by id: an omitted field keeps its current value. Get the id from List Event Types. Use Additional Fields for any of the many advanced Cal.com event-type settings not exposed directly here (e.g. booking limits, recurrence, locations) — it is merged into the same request. To clear Title/Description, pass a single space; Cal.com silently ignores a true empty string. Idempotent: repeating with the same input yields the same final state.', + idempotent: true, + }, + props: { + event_type_id: Property.Number({ + displayName: 'Event Type ID', + description: 'Get this from List Event Types.', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + required: false, + }), + description: Property.LongText({ + displayName: 'Description', + required: false, + }), + length_in_minutes: Property.Number({ + displayName: 'Length (Minutes)', + required: false, + }), + schedule_id: Property.Number({ + displayName: 'Schedule ID', + description: 'Assign a different availability schedule than the owner\'s default one. Get this from List Schedules.', + required: false, + }), + hidden: Property.StaticDropdown({ + displayName: 'Hidden', + description: 'Leave unset to keep the current visibility.', + required: false, + options: { + options: [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ], + }, + }), + additional_fields: Property.Json({ + displayName: 'Additional Fields', + description: 'Advanced event-type fields not listed above, as a JSON object matching the Cal.com v2 Update Event Type request body (e.g. {"bookingLimitsCount": {...}}).', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { + event_type_id, + title, + slug, + description, + length_in_minutes, + schedule_id, + hidden, + additional_fields, + } = propsValue; + + const body: Record = { + ...(additional_fields as Record | undefined), + }; + if (title !== undefined) body['title'] = title; + if (slug !== undefined) body['slug'] = slug; + if (description !== undefined) body['description'] = description; + if (length_in_minutes !== undefined) body['lengthInMinutes'] = length_in_minutes; + if (schedule_id !== undefined) body['scheduleId'] = schedule_id; + const hiddenValue = toOptionalBoolean(hidden); + if (hiddenValue !== undefined) body['hidden'] = hiddenValue; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.PATCH, + path: `/event-types/${event_type_id}`, + version: calcomCommon.versions.eventTypes, + body, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/update-my-profile.ts b/packages/pieces/community/cal-com/src/lib/actions/update-my-profile.ts new file mode 100644 index 000000000000..d93a57c48b4b --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/update-my-profile.ts @@ -0,0 +1,93 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { profileActionOutputSchema } from '../output-schemas'; + +export const calcomUpdateMyProfile = createAction({ + auth: calcomAuth, + name: 'calcom_update_my_profile', + classification: 'WRITE', + displayName: 'Update My Profile', + description: 'Update the connected Cal.com user\'s own profile. Only the fields you set are changed.', + audience: 'ai', + outputSchema: profileActionOutputSchema, + aiMetadata: { + description: + 'Partially updates the authenticated user\'s own profile: an omitted field keeps its current value. To clear Bio, pass a single space; Cal.com silently ignores a true empty string. Idempotent: repeating with the same input yields the same final state.', + idempotent: true, + }, + props: { + name: Property.ShortText({ + displayName: 'Name', + required: false, + }), + email: Property.ShortText({ + displayName: 'Email', + required: false, + }), + time_zone: Property.ShortText({ + displayName: 'Timezone', + description: 'IANA timezone, e.g. Europe/Berlin. Get valid values from List Timezones.', + required: false, + }), + default_schedule_id: Property.Number({ + displayName: 'Default Schedule ID', + description: 'Get this from List Schedules.', + required: false, + }), + week_start: Property.ShortText({ + displayName: 'Week Start', + description: 'e.g. Monday.', + required: false, + }), + time_format: Property.StaticDropdown({ + displayName: 'Time Format', + required: false, + options: { + options: [ + { label: '12-hour', value: '12' }, + { label: '24-hour', value: '24' }, + ], + }, + }), + locale: Property.ShortText({ + displayName: 'Locale', + required: false, + }), + bio: Property.LongText({ + displayName: 'Bio', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { + name, + email, + time_zone, + default_schedule_id, + week_start, + time_format, + locale, + bio, + } = propsValue; + + const body: Record = {}; + if (name !== undefined) body['name'] = name; + if (email !== undefined) body['email'] = email; + if (time_zone !== undefined) body['timeZone'] = time_zone; + if (default_schedule_id !== undefined) body['defaultScheduleId'] = default_schedule_id; + if (week_start !== undefined) body['weekStart'] = week_start; + if (time_format !== undefined) body['timeFormat'] = Number(time_format); + if (locale !== undefined) body['locale'] = locale; + if (bio !== undefined) body['bio'] = bio; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.PATCH, + path: '/me', + body, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/actions/update-schedule.ts b/packages/pieces/community/cal-com/src/lib/actions/update-schedule.ts new file mode 100644 index 000000000000..a2a777caa1b5 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/actions/update-schedule.ts @@ -0,0 +1,80 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { calcomAuth } from '../auth'; +import { calcomCommon } from '../common'; +import { scheduleActionOutputSchema } from '../output-schemas'; + +function toOptionalBoolean(value: string | undefined): boolean | undefined { + return value === undefined ? undefined : value === 'true'; +} + +export const calcomUpdateSchedule = createAction({ + auth: calcomAuth, + name: 'calcom_update_schedule', + classification: 'WRITE', + displayName: 'Update Schedule', + description: 'Update an existing availability schedule. Only the fields you set are changed.', + audience: 'ai', + outputSchema: scheduleActionOutputSchema, + aiMetadata: { + description: + 'Partially updates a schedule by id: an omitted field keeps its current value, except Availability and Date Overrides, which are fully replaced (not merged) when supplied — read the current schedule with Get Schedule first if you need to change one block while keeping the rest. Idempotent: repeating with the same input yields the same final state.', + idempotent: true, + }, + props: { + schedule_id: Property.Number({ + displayName: 'Schedule ID', + description: 'Get this from List Schedules.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Name', + required: false, + }), + time_zone: Property.ShortText({ + displayName: 'Timezone', + required: false, + }), + is_default: Property.StaticDropdown({ + displayName: 'Is Default', + description: 'Leave unset to keep the current default schedule unchanged.', + required: false, + options: { + options: [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ], + }, + }), + availability: Property.Json({ + displayName: 'Availability (full replacement)', + description: 'If set, replaces the entire weekly availability array, e.g. [{"days": ["Monday"], "startTime": "09:00", "endTime": "17:00"}].', + required: false, + }), + overrides: Property.Json({ + displayName: 'Date Overrides (full replacement)', + description: 'If set, replaces the entire date-overrides array, e.g. [{"date": "2026-05-20", "startTime": "12:00", "endTime": "13:00"}].', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + const { schedule_id, name, time_zone, is_default, availability, overrides } = propsValue; + + const body: Record = {}; + if (name !== undefined) body['name'] = name; + if (time_zone !== undefined) body['timeZone'] = time_zone; + const isDefaultValue = toOptionalBoolean(is_default); + if (isDefaultValue !== undefined) body['isDefault'] = isDefaultValue; + if (availability !== undefined) body['availability'] = availability; + if (overrides !== undefined) body['overrides'] = overrides; + + return await calcomCommon.calRequest({ + apiKey: auth.secret_text, + method: HttpMethod.PATCH, + path: `/schedules/${schedule_id}`, + version: calcomCommon.versions.schedules, + body, + }); + }, +}); diff --git a/packages/pieces/community/cal-com/src/lib/auth.ts b/packages/pieces/community/cal-com/src/lib/auth.ts new file mode 100644 index 000000000000..7998f10437e1 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/auth.ts @@ -0,0 +1,7 @@ +import { PieceAuth } from '@activepieces/pieces-framework'; + +export const calcomAuth = PieceAuth.SecretText({ + displayName: 'API Key', + description: 'API Key provided by cal.com', + required: true, +}); diff --git a/packages/pieces/community/cal-com/src/lib/common/client.ts b/packages/pieces/community/cal-com/src/lib/common/client.ts new file mode 100644 index 000000000000..7cd3ebbbb81a --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/common/client.ts @@ -0,0 +1,122 @@ +import { + AuthenticationType, + HttpError, + HttpMethod, + HttpRequest, + httpClient, +} from '@activepieces/pieces-common'; + +type QueryValue = string | number | boolean | undefined; + +interface CalPagination { + nextCursor: string | null; + hasMore: boolean; +} + +interface CalEnvelope { + status: 'success' | 'error'; + data: T; + pagination?: CalPagination; +} + +function toQueryParams( + query?: Record +): Record { + const params: Record = {}; + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) { + params[key] = String(value); + } + } + return params; +} + +function mapCalError(error: unknown): Error { + if (!(error instanceof HttpError)) { + return error instanceof Error ? error : new Error(String(error)); + } + + const status = error.response.status; + const body = error.response.body as + | { error?: { message?: string }; message?: string } + | undefined; + const message = body?.error?.message ?? body?.message ?? JSON.stringify(body); + + if (status === 403) { + return new Error(`Cal.com API key is not authorized for this request: ${message}`); + } + if (status === 404) { + return new Error(`Cal.com resource not found: ${message}`); + } + if (status === 429) { + return new Error(`Cal.com API rate limit exceeded: ${message}`); + } + return new Error(`Cal.com API error (${status}): ${message}`); +} + +interface CalRequestParams { + apiKey: string; + method: HttpMethod; + path: string; + version?: string; + query?: Record; + body?: unknown; +} + +async function sendCalRequest(params: CalRequestParams): Promise | undefined> { + const { apiKey, method, path, version, query, body } = params; + const request: HttpRequest = { + method, + url: `https://api.cal.com/v2${path}`, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: apiKey, + }, + headers: version ? { 'cal-api-version': version } : undefined, + queryParams: toQueryParams(query), + body, + }; + + let response; + try { + response = await httpClient.sendRequest>(request); + } catch (error) { + throw mapCalError(error); + } + if (!response.body) { + return undefined; + } + if (response.body.status === 'error') { + throw new Error(`Cal.com API returned an error: ${JSON.stringify(response.body)}`); + } + return response.body; +} + +async function calRequest(params: CalRequestParams): Promise { + const envelope = await sendCalRequest(params); + return envelope?.data as T; +} + +async function calRequestPaginated( + params: CalRequestParams +): Promise<{ data: T; nextCursor: string | null; hasMore: boolean }> { + const envelope = await sendCalRequest(params); + return { + data: envelope?.data as T, + nextCursor: envelope?.pagination?.nextCursor ?? null, + hasMore: envelope?.pagination?.hasMore ?? false, + }; +} + +export const calcomCommon = { + calRequest, + calRequestPaginated, + versions: { + eventTypes: '2026-06-12', + bookingsList: '2026-05-01', + bookingsItem: '2026-02-25', + attendees: '2024-08-13', + slots: '2024-09-04', + schedules: '2024-06-11', + }, +}; diff --git a/packages/pieces/community/cal-com/src/lib/common.ts b/packages/pieces/community/cal-com/src/lib/common/index.ts similarity index 85% rename from packages/pieces/community/cal-com/src/lib/common.ts rename to packages/pieces/community/cal-com/src/lib/common/index.ts index 5c93b6f3d036..0c159c17cf80 100644 --- a/packages/pieces/community/cal-com/src/lib/common.ts +++ b/packages/pieces/community/cal-com/src/lib/common/index.ts @@ -1,3 +1,5 @@ +export * from './client'; + export const enum EventTrigger { BOOKING_CREATED = 'BOOKING_CREATED', BOOKING_RESCHEDULED = 'BOOKING_RESCHEDULED', diff --git a/packages/pieces/community/cal-com/src/lib/output-schemas.ts b/packages/pieces/community/cal-com/src/lib/output-schemas.ts new file mode 100644 index 000000000000..8ac461c240c9 --- /dev/null +++ b/packages/pieces/community/cal-com/src/lib/output-schemas.ts @@ -0,0 +1,233 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const eventTypeFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Event Type ID' }, + { key: 'title', label: 'Title' }, + { key: 'slug', label: 'Slug' }, + { key: 'description', label: 'Description' }, + { key: 'lengthInMinutes', label: 'Length (Minutes)', format: 'number' }, + { key: 'hidden', label: 'Hidden', format: 'boolean' }, + { key: 'scheduleId', label: 'Schedule ID', format: 'number' }, + { key: 'bookingUrl', label: 'Booking URL', format: 'url' }, + { key: 'price', label: 'Price', format: 'number' }, + { key: 'currency', label: 'Currency' }, +]; + +const bookingFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Booking ID' }, + { key: 'uid', label: 'Booking UID' }, + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'status', label: 'Status' }, + { key: 'start', label: 'Start Time', format: 'datetime' }, + { key: 'end', label: 'End Time', format: 'datetime' }, + { key: 'duration', label: 'Duration (Minutes)', format: 'number' }, + { key: 'location', label: 'Location', format: 'url' }, + { key: 'meetingUrl', label: 'Meeting URL', format: 'url' }, + { key: 'cancellationReason', label: 'Cancellation Reason' }, + { key: 'eventTypeId', label: 'Event Type ID', format: 'number' }, + { + key: 'eventType', + label: 'Event Type', + children: [ + { key: 'id', label: 'Event Type ID' }, + { key: 'slug', label: 'Slug' }, + ], + }, + { + key: 'hosts', + label: 'Hosts', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Host ID' }, + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'timeZone', label: 'Timezone' }, + ], + }, + { + key: 'attendees', + label: 'Attendees', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'timeZone', label: 'Timezone' }, + { key: 'absent', label: 'Absent', format: 'boolean' }, + ], + }, +]; + +const scheduleFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Schedule ID' }, + { key: 'name', label: 'Name' }, + { key: 'timeZone', label: 'Timezone' }, + { key: 'isDefault', label: 'Is Default', format: 'boolean' }, + { + key: 'availability', + label: 'Availability', + labelKey: 'startTime', + listItems: [ + { key: 'days', label: 'Days' }, + { key: 'startTime', label: 'Start Time' }, + { key: 'endTime', label: 'End Time' }, + ], + }, + { + key: 'overrides', + label: 'Date Overrides', + labelKey: 'date', + listItems: [ + { key: 'date', label: 'Date', format: 'date' }, + { key: 'startTime', label: 'Start Time' }, + { key: 'endTime', label: 'End Time' }, + ], + }, +]; + +const profileFields: OutputSchema['fields'] = [ + { key: 'id', label: 'User ID' }, + { key: 'username', label: 'Username' }, + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'bio', label: 'Bio' }, + { key: 'timeZone', label: 'Timezone' }, + { key: 'weekStart', label: 'Week Start' }, + { key: 'timeFormat', label: 'Time Format', format: 'number' }, + { key: 'defaultScheduleId', label: 'Default Schedule ID', format: 'number' }, + { key: 'avatarUrl', label: 'Avatar URL', format: 'image' }, +]; + +export const listEventTypesActionOutputSchema: OutputSchema = { + itemLabel: '{title}', + fields: [ + { key: 'eventTypes', label: 'Event Types', value: '', listItems: eventTypeFields }, + ], +}; + +export const eventTypeActionOutputSchema: OutputSchema = { + fields: eventTypeFields, +}; + +export const deleteEventTypeActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Event Type ID' }, + { key: 'title', label: 'Title' }, + { key: 'slug', label: 'Slug' }, + { key: 'lengthInMinutes', label: 'Length (Minutes)', format: 'number' }, + ], +}; + +export const listBookingsActionOutputSchema: OutputSchema = { + fields: [ + { key: 'bookings', label: 'Bookings', labelKey: 'title', listItems: bookingFields }, + { key: 'nextCursor', label: 'Next Cursor' }, + { key: 'hasMore', label: 'Has More', format: 'boolean' }, + ], +}; + +export const bookingActionOutputSchema: OutputSchema = { + fields: bookingFields, +}; + +export const addAttendeeActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Attendee ID' }, + { key: 'bookingId', label: 'Booking ID' }, + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'timeZone', label: 'Timezone' }, + { key: 'absent', label: 'Absent', format: 'boolean' }, + ], +}; + +export const availableSlotsActionOutputSchema: OutputSchema = { + fields: [ + { + key: 'slots', + label: 'Available Slots by Date', + value: '', + dynamicKey: true, + description: 'Each key is a date (YYYY-MM-DD); each value is an array of slot objects with a `start` ISO timestamp.', + }, + ], +}; + +export const listSchedulesActionOutputSchema: OutputSchema = { + itemLabel: '{name}', + fields: [ + { key: 'schedules', label: 'Schedules', value: '', listItems: scheduleFields }, + ], +}; + +export const scheduleActionOutputSchema: OutputSchema = { + fields: scheduleFields, +}; + +export const listTimezonesActionOutputSchema: OutputSchema = { + itemLabel: '{city}, {country}', + fields: [ + { + key: 'timezones', + label: 'Timezones', + value: '', + listItems: [ + { key: 'city', label: 'City' }, + { key: 'timezone', label: 'Timezone' }, + { key: 'country', label: 'Country' }, + { key: 'region', label: 'Region' }, + ], + }, + ], +}; + +export const profileActionOutputSchema: OutputSchema = { + fields: profileFields, +}; + +const bookingEventFields: OutputSchema['fields'] = [ + { key: 'triggerEvent', label: 'Trigger Event' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { + key: 'payload', + label: 'Booking', + children: [ + { key: 'uid', label: 'Booking UID' }, + { key: 'title', label: 'Title' }, + { key: 'type', label: 'Event Type Slug' }, + { key: 'description', label: 'Description' }, + { key: 'startTime', label: 'Start Time', format: 'datetime' }, + { key: 'endTime', label: 'End Time', format: 'datetime' }, + { key: 'status', label: 'Status' }, + { key: 'location', label: 'Location' }, + { key: 'eventTypeId', label: 'Event Type ID', format: 'number' }, + { key: 'cancellationReason', label: 'Cancellation Reason' }, + { key: 'rescheduleUid', label: 'Reschedule UID' }, + { key: 'rescheduleStartTime', label: 'Rescheduled Start Time', format: 'datetime' }, + { key: 'rescheduleEndTime', label: 'Rescheduled End Time', format: 'datetime' }, + { + key: 'organizer', + label: 'Organizer', + children: [ + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'timeZone', label: 'Timezone' }, + ], + }, + { + key: 'attendees', + label: 'Attendees', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'timeZone', label: 'Timezone' }, + ], + }, + ], + }, +]; + +export const bookingEventTriggerOutputSchema: OutputSchema = { + fields: bookingEventFields, +}; diff --git a/packages/pieces/community/cal-com/src/lib/triggers/index.ts b/packages/pieces/community/cal-com/src/lib/triggers/index.ts index 508187009bda..e93690550da2 100644 --- a/packages/pieces/community/cal-com/src/lib/triggers/index.ts +++ b/packages/pieces/community/cal-com/src/lib/triggers/index.ts @@ -1,4 +1,5 @@ import { EventTrigger } from '../common'; +import { bookingEventTriggerOutputSchema } from '../output-schemas'; import { registerWebhooks } from './register-webhook'; export const triggers = [ @@ -194,5 +195,6 @@ export const triggers = [ displayName: eventTrigger.displayName, sampleData: eventTrigger.sampleData, description: `Create a webhook to monitor when ${eventTrigger.displayName}`, + outputSchema: bookingEventTriggerOutputSchema, }) ); diff --git a/packages/pieces/community/cal-com/src/lib/triggers/register-webhook.ts b/packages/pieces/community/cal-com/src/lib/triggers/register-webhook.ts index 775b015008a0..767cc2784fd0 100644 --- a/packages/pieces/community/cal-com/src/lib/triggers/register-webhook.ts +++ b/packages/pieces/community/cal-com/src/lib/triggers/register-webhook.ts @@ -1,5 +1,6 @@ import { createTrigger, + OutputSchema, Trigger, TriggerStrategy, Property, @@ -9,7 +10,7 @@ import { HttpRequest, HttpMethod, } from '@activepieces/pieces-common'; -import { calcomAuth } from '../..'; +import { calcomAuth } from '../auth'; export const registerWebhooks = ({ name, @@ -17,12 +18,14 @@ export const registerWebhooks = ({ displayName, sampleData, aiMetadata, + outputSchema, }: { name: string; description: string; displayName: string; sampleData: Record; aiMetadata?: { description: string }; + outputSchema?: OutputSchema; }) => createTrigger({ auth: calcomAuth, @@ -31,6 +34,7 @@ export const registerWebhooks = ({ description, displayName, aiMetadata, + outputSchema, props: {}, sampleData: sampleData, type: TriggerStrategy.WEBHOOK, From fe073b32503825d8432d9cb9b0ed4224e15fcfae Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:48:44 +0530 Subject: [PATCH 2/8] feat(apitemplate-io): add AI actions and output schemas (#15862) --- .../community/apitemplate-io/package.json | 2 +- .../community/apitemplate-io/src/index.ts | 12 +- .../src/lib/actions/create-image.ts | 2 + .../src/lib/actions/create-pdf-from-html.ts | 2 + .../lib/actions/create-pdf-from-markdown.ts | 98 +++++++++++++ .../src/lib/actions/create-pdf-from-url.ts | 2 + .../src/lib/actions/create-pdf.ts | 2 + .../src/lib/actions/delete-object.ts | 2 + .../lib/actions/get-account-information.ts | 2 + .../src/lib/actions/get-template.ts | 54 +++++++ .../src/lib/actions/list-objects.ts | 2 + .../src/lib/actions/list-templates.ts | 116 +++++++++++++++ .../src/lib/actions/merge-pdfs.ts | 80 +++++++++++ .../src/lib/actions/update-template.ts | 78 ++++++++++ .../apitemplate-io/src/lib/common/props.ts | 63 +++++++++ .../apitemplate-io/src/lib/output-schemas.ts | 133 ++++++++++++++++++ 16 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-markdown.ts create mode 100644 packages/pieces/community/apitemplate-io/src/lib/actions/get-template.ts create mode 100644 packages/pieces/community/apitemplate-io/src/lib/actions/list-templates.ts create mode 100644 packages/pieces/community/apitemplate-io/src/lib/actions/merge-pdfs.ts create mode 100644 packages/pieces/community/apitemplate-io/src/lib/actions/update-template.ts create mode 100644 packages/pieces/community/apitemplate-io/src/lib/output-schemas.ts diff --git a/packages/pieces/community/apitemplate-io/package.json b/packages/pieces/community/apitemplate-io/package.json index a8e7f6cefd38..c93f2584e15c 100644 --- a/packages/pieces/community/apitemplate-io/package.json +++ b/packages/pieces/community/apitemplate-io/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-apitemplate-io", - "version": "0.2.0", + "version": "0.3.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/apitemplate-io/src/index.ts b/packages/pieces/community/apitemplate-io/src/index.ts index 15b28c8894b0..cb4f756a4fce 100644 --- a/packages/pieces/community/apitemplate-io/src/index.ts +++ b/packages/pieces/community/apitemplate-io/src/index.ts @@ -7,6 +7,11 @@ import { deleteObject } from './lib/actions/delete-object'; import { listObjects } from './lib/actions/list-objects'; import { createPdfFromHtml } from './lib/actions/create-pdf-from-html'; import { createPdfFromUrl } from './lib/actions/create-pdf-from-url'; +import { createPdfFromMarkdown } from './lib/actions/create-pdf-from-markdown'; +import { listTemplates } from './lib/actions/list-templates'; +import { getTemplate } from './lib/actions/get-template'; +import { updateTemplate } from './lib/actions/update-template'; +import { mergePdfs } from './lib/actions/merge-pdfs'; import { PieceCategory } from '@activepieces/pieces-framework'; import { createCustomApiCallAction } from '@activepieces/pieces-common'; import { ApitemplateAuthConfig, ApitemplateRegion, getRegionalBaseUrl } from './lib/common/client'; @@ -14,7 +19,7 @@ import { ApitemplateAuthConfig, ApitemplateRegion, getRegionalBaseUrl } from './ export const apitemplateIo = createPiece({ displayName: 'APITemplate.io', auth: ApitemplateAuth, - minimumSupportedRelease: '0.36.1', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/apitemplate-io.png', categories: [PieceCategory.CONTENT_AND_FILES, PieceCategory.PRODUCTIVITY], authors: ['Sanket6652'], @@ -26,6 +31,11 @@ export const apitemplateIo = createPiece({ deleteObject, getAccountInformation, listObjects, + createPdfFromMarkdown, + listTemplates, + getTemplate, + updateTemplate, + mergePdfs, createCustomApiCallAction({ auth: ApitemplateAuth, baseUrl: (auth) => { diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/create-image.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/create-image.ts index 8f78018ea675..2a83508f4822 100644 --- a/packages/pieces/community/apitemplate-io/src/lib/actions/create-image.ts +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/create-image.ts @@ -3,10 +3,12 @@ import { ApitemplateAuth } from '../common/auth'; import { ApitemplateAuthConfig, ApitemplateRegion, makeRequest } from '../common/client'; import { HttpMethod } from '@activepieces/pieces-common'; import { templateIdDropdown } from '../common/props'; +import { createImageOutputSchema } from '../output-schemas'; export const createImage = createAction({ auth: ApitemplateAuth, name: 'createImage', + outputSchema: createImageOutputSchema, classification: 'WRITE', displayName: 'Create Image', description: 'Creates an image from a template with provided data.', diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-html.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-html.ts index ce1b39988e07..3289e63a8d7f 100644 --- a/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-html.ts +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-html.ts @@ -2,10 +2,12 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { ApitemplateAuth } from '../common/auth'; import { ApitemplateAuthConfig, ApitemplateRegion, makeRequest } from '../common/client'; import { HttpMethod } from '@activepieces/pieces-common'; +import { createPdfFromHtmlOutputSchema } from '../output-schemas'; export const createPdfFromHtml = createAction({ auth: ApitemplateAuth, name: 'createPdfFromHtml', + outputSchema: createPdfFromHtmlOutputSchema, classification: 'WRITE', displayName: 'Create PDF From HTML', description: 'Creates a PDF from HTML.', diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-markdown.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-markdown.ts new file mode 100644 index 000000000000..3f00abbbf45f --- /dev/null +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-markdown.ts @@ -0,0 +1,98 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ApitemplateAuth } from '../common/auth'; +import { ApitemplateRegion, makeRequest } from '../common/client'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { apitemplateIoCreatePdfFromMarkdownOutputSchema } from '../output-schemas'; + +export const createPdfFromMarkdown = createAction({ + auth: ApitemplateAuth, + name: 'apitemplate_io_create_pdf_from_markdown', + outputSchema: apitemplateIoCreatePdfFromMarkdownOutputSchema, + classification: 'WRITE', + displayName: 'Create PDF From Markdown', + description: 'Creates a PDF from Markdown content.', + audience: 'ai', + aiMetadata: { + description: + 'Renders a new PDF from raw Markdown text (with optional CSS and templating data). Use when the source is Markdown rather than a saved template, raw HTML, or a live URL. Requires the Markdown body. Not idempotent: each call generates and stores a new PDF.', + idempotent: false, + }, + props: { + body: Property.LongText({ + displayName: 'Markdown Content', + description: 'The Markdown content to convert to PDF.', + required: true, + }), + css: Property.LongText({ + displayName: 'CSS Styles', + description: 'Optional CSS styles to apply to the rendered Markdown.', + required: false, + }), + data: Property.Json({ + displayName: 'Template Data', + description: 'Optional JSON data to use for templating the Markdown content.', + required: false, + }), + expiration: Property.Number({ + displayName: 'Expiration (minutes)', + description: + 'Expiration of the generated PDF in minutes. Use 0 to store permanently, or 1-10080 minutes (7 days) to specify expiration.', + required: false, + defaultValue: 0, + }), + meta: Property.ShortText({ + displayName: 'External Reference ID', + description: 'Specify an external reference ID for your own reference. It appears in the list-objects response.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const authConfig = auth.props; + const { body, css, data, expiration, meta } = propsValue; + + const queryParams = new URLSearchParams(); + + if (expiration !== undefined && expiration !== 0) { + queryParams.append('expiration', expiration.toString()); + } + + if (meta) { + queryParams.append('meta', meta); + } + + const endpoint = `/create-pdf-from-markdown${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + const requestBody: Record = { body }; + + if (css) { + requestBody['css'] = css; + } + + if (data) { + requestBody['data'] = data; + } + + try { + const response = await makeRequest( + authConfig.apiKey, + HttpMethod.POST, + endpoint, + requestBody, + undefined, + authConfig.region as ApitemplateRegion + ); + + return response; + } catch (error: any) { + if (error.message.includes('502') && authConfig.region !== 'default') { + throw new Error( + `${error.message}\n\nThe ${authConfig.region} region appears to be experiencing issues. ` + + `Consider switching to the 'default' region in your authentication settings or try again later.` + ); + } + throw error; + } + }, +}); diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-url.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-url.ts index 8e58e04fba0f..58e4da3f449c 100644 --- a/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-url.ts +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf-from-url.ts @@ -2,10 +2,12 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { ApitemplateAuth } from '../common/auth'; import { ApitemplateRegion, makeRequest } from '../common/client'; import { HttpMethod } from '@activepieces/pieces-common'; +import { createPdfFromHtmlOutputSchema } from '../output-schemas'; export const createPdfFromUrl = createAction({ auth: ApitemplateAuth, name: 'createPdfFromUrl', + outputSchema: createPdfFromHtmlOutputSchema, classification: 'WRITE', displayName: 'Create PDF From URL', description: 'Creates a PDF from a webpage URL.', diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf.ts index 5b19f6e0169b..e9271fe4d53a 100644 --- a/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf.ts +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/create-pdf.ts @@ -3,10 +3,12 @@ import { ApitemplateAuth } from '../common/auth'; import { ApitemplateRegion, makeRequest } from '../common/client'; import { HttpMethod } from '@activepieces/pieces-common'; import { templateIdDropdown } from '../common/props'; +import { createImageOutputSchema } from '../output-schemas'; export const createPdf = createAction({ auth: ApitemplateAuth, name: 'createPdf', + outputSchema: createImageOutputSchema, classification: 'WRITE', displayName: 'Create PDF', description: 'Creates a PDF from a template with provided data.', diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/delete-object.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/delete-object.ts index 5a0989e4fade..8a3fb9315518 100644 --- a/packages/pieces/community/apitemplate-io/src/lib/actions/delete-object.ts +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/delete-object.ts @@ -3,10 +3,12 @@ import { ApitemplateAuth } from '../common/auth'; import { ApitemplateRegion, makeRequest } from '../common/client'; import { HttpMethod } from '@activepieces/pieces-common'; import { transactionRefDropdown } from '../common/props'; +import { deleteObjectOutputSchema } from '../output-schemas'; export const deleteObject = createAction({ auth: ApitemplateAuth, name: 'deleteObject', + outputSchema: deleteObjectOutputSchema, classification: 'DESTRUCTIVE', displayName: 'Delete Object', description: 'Deletes a generated PDF or image by its transaction reference or object ID.', diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/get-account-information.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/get-account-information.ts index bd8b5aa8a3a2..440845eb8918 100644 --- a/packages/pieces/community/apitemplate-io/src/lib/actions/get-account-information.ts +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/get-account-information.ts @@ -2,10 +2,12 @@ import { createAction } from '@activepieces/pieces-framework'; import { ApitemplateAuth } from '../common/auth'; import { ApitemplateRegion, makeRequest } from '../common/client'; import { HttpMethod } from '@activepieces/pieces-common'; +import { getAccountInformationOutputSchema } from '../output-schemas'; export const getAccountInformation = createAction({ auth: ApitemplateAuth, name: 'getAccountInformation', + outputSchema: getAccountInformationOutputSchema, classification: 'READ', displayName: 'Get Account Information', description: 'Retrieves account information including usage statistics and account details.', diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/get-template.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/get-template.ts new file mode 100644 index 000000000000..80e6e3450708 --- /dev/null +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/get-template.ts @@ -0,0 +1,54 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ApitemplateAuth } from '../common/auth'; +import { ApitemplateRegion, makeRequest } from '../common/client'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pdfTemplateIdDropdown } from '../common/props'; +import { apitemplateIoGetTemplateOutputSchema } from '../output-schemas'; + +export const getTemplate = createAction({ + auth: ApitemplateAuth, + name: 'apitemplate_io_get_template', + outputSchema: apitemplateIoGetTemplateOutputSchema, + classification: 'READ', + displayName: 'Get Template', + description: 'Retrieves the body, CSS, and settings of a PDF template.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches the HTML body, CSS, and print settings of a single PDF template, identified by its template ID (get one from list-templates). This is an experimental vendor endpoint limited to PDF templates and may not be available on every account. Read-only and idempotent.', + idempotent: true, + }, + props: { + templateId: pdfTemplateIdDropdown, + }, + async run({ auth, propsValue }) { + const authConfig = auth.props; + const { templateId } = propsValue; + + const queryParams = new URLSearchParams(); + queryParams.append('template_id', templateId); + + const endpoint = `/get-template?${queryParams.toString()}`; + + try { + const response = await makeRequest( + authConfig.apiKey, + HttpMethod.GET, + endpoint, + undefined, + undefined, + authConfig.region as ApitemplateRegion + ); + + return response; + } catch (error: any) { + if (error.message.includes('502') && authConfig.region !== 'default') { + throw new Error( + `${error.message}\n\nThe ${authConfig.region} region appears to be experiencing issues. ` + + `Consider switching to the 'default' region in your authentication settings or try again later.` + ); + } + throw error; + } + }, +}); diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/list-objects.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/list-objects.ts index 02e767932336..819b51818217 100644 --- a/packages/pieces/community/apitemplate-io/src/lib/actions/list-objects.ts +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/list-objects.ts @@ -2,10 +2,12 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { ApitemplateAuth } from '../common/auth'; import { ApitemplateRegion, makeRequest } from '../common/client'; import { HttpMethod } from '@activepieces/pieces-common'; +import { listObjectsOutputSchema } from '../output-schemas'; export const listObjects = createAction({ auth: ApitemplateAuth, name: 'listObjects', + outputSchema: listObjectsOutputSchema, classification: 'SEARCH', displayName: 'List Objects', description: diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/list-templates.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/list-templates.ts new file mode 100644 index 000000000000..e275cb141485 --- /dev/null +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/list-templates.ts @@ -0,0 +1,116 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ApitemplateAuth } from '../common/auth'; +import { ApitemplateRegion, makeRequest } from '../common/client'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { apitemplateIoListTemplatesOutputSchema } from '../output-schemas'; + +export const listTemplates = createAction({ + auth: ApitemplateAuth, + name: 'apitemplate_io_list_templates', + outputSchema: apitemplateIoListTemplatesOutputSchema, + classification: 'SEARCH', + displayName: 'List Templates', + description: 'Retrieves a list of the account templates and their information.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the PDF and image templates saved in the account, optionally narrowed by format, template ID, or group name. Use to discover a template ID before calling create-pdf/create-image, get-template, or update-template. Supports limit/offset pagination (default 300 per call). Read-only and idempotent.', + idempotent: true, + }, + props: { + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum number of templates to return (default: 300).', + required: false, + defaultValue: 300, + }), + offset: Property.Number({ + displayName: 'Offset', + description: 'Number of templates to skip for pagination (default: 0).', + required: false, + defaultValue: 0, + }), + format: Property.StaticDropdown({ + displayName: 'Format', + description: 'Filter templates by format.', + required: false, + options: { + options: [ + { label: 'PDF', value: 'PDF' }, + { label: 'JPEG', value: 'JPEG' }, + ], + }, + }), + templateId: Property.ShortText({ + displayName: 'Template ID', + description: 'Filter templates by template ID.', + required: false, + }), + groupName: Property.ShortText({ + displayName: 'Group Name', + description: 'Filter templates by group name.', + required: false, + }), + withLayerInfo: Property.Checkbox({ + displayName: 'With Layer Info', + description: 'Return layer information for image templates.', + required: false, + defaultValue: false, + }), + }, + async run({ auth, propsValue }) { + const authConfig = auth.props; + const { limit, offset, format, templateId, groupName, withLayerInfo } = propsValue; + + const queryParams = new URLSearchParams(); + + if (limit !== undefined && limit !== 300) { + queryParams.append('limit', limit.toString()); + } + + if (offset !== undefined && offset !== 0) { + queryParams.append('offset', offset.toString()); + } + + if (format) { + queryParams.append('format', format); + } + + if (templateId) { + queryParams.append('template_id', templateId); + } + + if (groupName) { + queryParams.append('group_name', groupName); + } + + if (withLayerInfo) { + queryParams.append('with_layer_info', '1'); + } + + const endpoint = `/list-templates${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + try { + const response = await makeRequest( + authConfig.apiKey, + HttpMethod.GET, + endpoint, + undefined, + undefined, + authConfig.region as ApitemplateRegion + ); + + return response; + } catch (error: any) { + if (error.message.includes('502') && authConfig.region !== 'default') { + throw new Error( + `${error.message}\n\nThe ${authConfig.region} region appears to be experiencing issues. ` + + `Consider switching to the 'default' region in your authentication settings or try again later.` + ); + } + throw error; + } + }, +}); diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/merge-pdfs.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/merge-pdfs.ts new file mode 100644 index 000000000000..e3472e4ecd85 --- /dev/null +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/merge-pdfs.ts @@ -0,0 +1,80 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ApitemplateAuth } from '../common/auth'; +import { ApitemplateRegion, makeRequest } from '../common/client'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { apitemplateIoMergePdfsOutputSchema } from '../output-schemas'; + +export const mergePdfs = createAction({ + auth: ApitemplateAuth, + name: 'apitemplate_io_merge_pdfs', + outputSchema: apitemplateIoMergePdfsOutputSchema, + classification: 'WRITE', + displayName: 'Merge PDFs', + description: 'Merges multiple PDFs into a single PDF file.', + audience: 'ai', + aiMetadata: { + description: + 'Joins an ordered list of PDF URLs (normal http/https URLs or base64 data: URIs) into a single PDF, in the order given. Use after generating individual PDFs to combine them into one document. Requires at least one URL. Not idempotent: each call generates and stores a new merged PDF.', + idempotent: false, + }, + props: { + urls: Property.Array({ + displayName: 'PDF URLs', + description: 'URLs of the PDFs to merge, in order. Supports http/https URLs and data: URIs.', + required: true, + }), + expiration: Property.Number({ + displayName: 'Expiration (minutes)', + description: + 'Expiration of the generated PDF in minutes. Use 0 to store permanently, or 1-43200 minutes (30 days) to specify expiration.', + required: false, + defaultValue: 0, + }), + meta: Property.ShortText({ + displayName: 'External Reference ID', + description: 'Specify an external reference ID for your own reference. It appears in the list-objects response.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const authConfig = auth.props; + const { urls, expiration, meta } = propsValue; + + const queryParams = new URLSearchParams(); + + if (meta) { + queryParams.append('meta', meta); + } + + const endpoint = `/merge-pdfs${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + const requestBody: Record = { + urls: urls as string[], + }; + + if (expiration !== undefined && expiration !== 0) { + requestBody['expiration'] = expiration; + } + + try { + const response = await makeRequest( + authConfig.apiKey, + HttpMethod.POST, + endpoint, + requestBody, + undefined, + authConfig.region as ApitemplateRegion + ); + + return response; + } catch (error: any) { + if (error.message.includes('502') && authConfig.region !== 'default') { + throw new Error( + `${error.message}\n\nThe ${authConfig.region} region appears to be experiencing issues. ` + + `Consider switching to the 'default' region in your authentication settings or try again later.` + ); + } + throw error; + } + }, +}); diff --git a/packages/pieces/community/apitemplate-io/src/lib/actions/update-template.ts b/packages/pieces/community/apitemplate-io/src/lib/actions/update-template.ts new file mode 100644 index 000000000000..1ae2f5ce3377 --- /dev/null +++ b/packages/pieces/community/apitemplate-io/src/lib/actions/update-template.ts @@ -0,0 +1,78 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ApitemplateAuth } from '../common/auth'; +import { ApitemplateRegion, makeRequest } from '../common/client'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { pdfTemplateIdDropdown } from '../common/props'; +import { apitemplateIoUpdateTemplateOutputSchema } from '../output-schemas'; + +export const updateTemplate = createAction({ + auth: ApitemplateAuth, + name: 'apitemplate_io_update_template', + outputSchema: apitemplateIoUpdateTemplateOutputSchema, + classification: 'WRITE', + displayName: 'Update Template', + description: 'Updates the HTML body, CSS, or settings of a PDF template.', + audience: 'ai', + aiMetadata: { + description: + 'Updates the HTML body, CSS, and/or print settings of an existing PDF template, identified by its template ID (get one from list-templates). Any field left empty leaves the current value unchanged. This is an experimental vendor endpoint limited to PDF templates and may not be available on every account. Update-to-state, idempotent.', + idempotent: true, + }, + props: { + templateId: pdfTemplateIdDropdown, + body: Property.LongText({ + displayName: 'HTML Body', + description: 'New HTML body for the template. Leave empty to keep the current body.', + required: false, + }), + css: Property.LongText({ + displayName: 'CSS', + description: 'New CSS for the template. Leave empty to keep the current CSS.', + required: false, + }), + settings: Property.Json({ + displayName: 'Settings', + description: 'New print settings for the template. Leave empty to keep the current settings.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const authConfig = auth.props; + const { templateId, body, css, settings } = propsValue; + + const requestBody: Record = { template_id: templateId }; + + if (body !== undefined) { + requestBody['body'] = body; + } + + if (css !== undefined) { + requestBody['css'] = css; + } + + if (settings !== undefined) { + requestBody['settings'] = settings; + } + + try { + const response = await makeRequest( + authConfig.apiKey, + HttpMethod.POST, + '/update-template', + requestBody, + undefined, + authConfig.region as ApitemplateRegion + ); + + return response; + } catch (error: any) { + if (error.message.includes('502') && authConfig.region !== 'default') { + throw new Error( + `${error.message}\n\nThe ${authConfig.region} region appears to be experiencing issues. ` + + `Consider switching to the 'default' region in your authentication settings or try again later.` + ); + } + throw error; + } + }, +}); diff --git a/packages/pieces/community/apitemplate-io/src/lib/common/props.ts b/packages/pieces/community/apitemplate-io/src/lib/common/props.ts index 785f54126f71..32b2bb974b2b 100644 --- a/packages/pieces/community/apitemplate-io/src/lib/common/props.ts +++ b/packages/pieces/community/apitemplate-io/src/lib/common/props.ts @@ -109,6 +109,69 @@ export const templateIdDropdown = Property.Dropdown({ }, }); +export const pdfTemplateIdDropdown = Property.Dropdown({ + auth: ApitemplateAuth, + displayName: 'Template ID', + description: 'Only PDF templates are shown; this endpoint does not support image templates.', + required: true, + refreshers: ['auth'], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + options: [], + placeholder: 'Please connect your account first', + }; + } + + const authConfig = auth.props; + + if (!authConfig.apiKey || !authConfig.region) { + return { + disabled: true, + options: [], + placeholder: 'Please complete authentication setup.', + }; + } + + try { + const response = await makeRequest( + authConfig.apiKey, + HttpMethod.GET, + '/list-templates?format=PDF', + undefined, + undefined, + authConfig.region as ApitemplateRegion + ); + + const templates = response?.templates || []; + + if (!Array.isArray(templates) || templates.length === 0) { + return { + disabled: false, + options: [], + placeholder: 'No PDF templates found', + }; + } + + return { + disabled: false, + options: templates.map((template: any) => ({ + label: `${template.name} - ${template.status}`, + value: template.template_id, + })), + }; + } catch (error) { + console.error('Error loading templates:', error); + return { + disabled: true, + options: [], + placeholder: 'Error loading templates', + }; + } + }, +}); + export const transactionRefDropdown = Property.Dropdown({ auth: ApitemplateAuth, displayName: 'Transaction Reference', diff --git a/packages/pieces/community/apitemplate-io/src/lib/output-schemas.ts b/packages/pieces/community/apitemplate-io/src/lib/output-schemas.ts new file mode 100644 index 000000000000..d24f0a05b476 --- /dev/null +++ b/packages/pieces/community/apitemplate-io/src/lib/output-schemas.ts @@ -0,0 +1,133 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const postActionFields: OutputSchema['fields'] = [ + { key: 'action', label: 'Action' }, + { key: 'name', label: 'Name' }, + { key: 'bucket', label: 'Bucket' }, + { key: 'status', label: 'Status' }, + { key: 'file', label: 'File' }, + { key: 'message', label: 'Message' }, +]; + +export const getAccountInformationOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Status' }, + { key: 'subscription_product', label: 'Subscription Product' }, + { key: 'subscription_current_period_start', label: 'Subscription Current Period Start', format: 'datetime' }, + { key: 'subscription_current_period_end', label: 'Subscription Current Period End', format: 'datetime' }, + { key: 'subscription_status', label: 'Subscription Status' }, + { key: 'subscription_interval', label: 'Subscription Interval' }, + { key: 'api_quota', label: 'API Quota', format: 'number' }, + { key: 'api_remaining', label: 'API Remaining', format: 'number' }, + { key: 'api_used', label: 'API Used', format: 'number' }, + { key: 'template_remaining', label: 'Template Remaining', format: 'number' }, + { key: 'template_count', label: 'Template Count', format: 'number' }, + { key: 'template_quota', label: 'Template Quota', format: 'number' }, + { key: 'message', label: 'Message' }, + ], +}; + +export const deleteObjectOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Status' }, + { key: 'transaction_ref', label: 'Transaction Ref' }, + ], +}; + +export const createImageOutputSchema: OutputSchema = { + fields: [ + { key: 'download_url', label: 'Download URL', format: 'url' }, + { key: 'transaction_ref', label: 'Transaction Ref' }, + { key: 'total_pages', label: 'Total Pages', format: 'number' }, + { key: 'status', label: 'Status' }, + { key: 'template_id', label: 'Template ID' }, + ], +}; + +export const createPdfFromHtmlOutputSchema: OutputSchema = { + fields: [ + { key: 'download_url', label: 'Download URL', format: 'url' }, + { key: 'transaction_ref', label: 'Transaction Ref' }, + { key: 'total_pages', label: 'Total Pages', format: 'number' }, + { key: 'status', label: 'Status' }, + ], +}; + +export const apitemplateIoCreatePdfFromMarkdownOutputSchema: OutputSchema = { + fields: [ + { key: 'download_url', label: 'Download URL', format: 'url' }, + { key: 'transaction_ref', label: 'Transaction Ref' }, + { key: 'total_pages', label: 'Total Pages', format: 'number' }, + { key: 'status', label: 'Status' }, + { key: 'post_actions', label: 'Post Actions', labelKey: 'name', listItems: postActionFields }, + { key: 'template_id', label: 'Template ID' }, + ], +}; + +export const apitemplateIoGetTemplateOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Status' }, + { key: 'template_id', label: 'Template ID' }, + { key: 'body', label: 'Body', format: 'html' }, + { key: 'css', label: 'CSS' }, + { key: 'sample_json', label: 'Sample JSON' }, + { key: 'settings', label: 'Settings' }, + ], +}; + +export const listObjectsOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Status' }, + { + key: 'objects', + label: 'Objects', + labelKey: 'transaction_ref', + listItems: [ + { key: 'transaction_ref', label: 'Transaction Ref' }, + { key: 'template_id', label: 'Template ID' }, + { key: 'description', label: 'Description' }, + { key: 'meta', label: 'Meta' }, + { key: 'source', label: 'Source' }, + { key: 'transaction_type', label: 'Transaction Type' }, + { key: 'primary_url', label: 'Primary URL', format: 'url' }, + { key: 'secondary_url', label: 'Secondary URL', format: 'url' }, + { key: 'deleted_at', label: 'Deleted At', format: 'datetime' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + ], + }, + ], +}; + +export const apitemplateIoListTemplatesOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Status' }, + { + key: 'templates', + label: 'Templates', + labelKey: 'name', + listItems: [ + { key: 'template_id', label: 'Template ID' }, + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, + { key: 'format', label: 'Format' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + { key: 'group_name', label: 'Group Name' }, + ], + }, + ], +}; + +export const apitemplateIoMergePdfsOutputSchema: OutputSchema = { + fields: [ + { key: 'primary_url', label: 'Primary URL', format: 'url' }, + { key: 'status', label: 'Status' }, + { key: 'transaction_ref', label: 'Transaction Ref' }, + { key: 'total_pages', label: 'Total Pages', format: 'number' }, + { key: 'post_actions', label: 'Post Actions', labelKey: 'name', listItems: postActionFields }, + ], +}; + +export const apitemplateIoUpdateTemplateOutputSchema: OutputSchema = { + fields: [{ key: 'status', label: 'Status' }], +}; From 5dc564ca52889b1c20492069f82526e17229f45b Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:24:22 +0530 Subject: [PATCH 3/8] fix(reoon-verifier): stop email verification actions being mislabeled as Write in the agent tool picker (#15863) --- packages/pieces/community/reoon-verifier/package.json | 2 +- packages/pieces/community/reoon-verifier/src/index.ts | 2 +- .../src/lib/actions/bulk-email-verification-status.ts | 1 + .../reoon-verifier/src/lib/actions/bulk-email-verification.ts | 1 + .../src/lib/actions/create-bulk-email-verification.ts | 1 + .../src/lib/actions/get-bulk-email-verification-result.ts | 1 + .../reoon-verifier/src/lib/actions/verify-email-address.ts | 1 + .../community/reoon-verifier/src/lib/actions/verify-email.ts | 1 + 8 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/pieces/community/reoon-verifier/package.json b/packages/pieces/community/reoon-verifier/package.json index b3552edf9917..388bac9519d5 100644 --- a/packages/pieces/community/reoon-verifier/package.json +++ b/packages/pieces/community/reoon-verifier/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-reoon-verifier", - "version": "0.1.7", + "version": "0.2.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/reoon-verifier/src/index.ts b/packages/pieces/community/reoon-verifier/src/index.ts index 9958d0605390..f476d98c0588 100644 --- a/packages/pieces/community/reoon-verifier/src/index.ts +++ b/packages/pieces/community/reoon-verifier/src/index.ts @@ -49,7 +49,7 @@ export const reoonEmailVerifyAuth = PieceAuth.SecretText({ export const reoonEmailVerify = createPiece({ displayName: 'Reoon Email Verifier', auth: reoonEmailVerifyAuth, - minimumSupportedRelease: '0.87.0', + minimumSupportedRelease: '0.88.2', categories: [PieceCategory.MARKETING], logoUrl: 'https://cdn.activepieces.com/pieces/reoon-verifier.png', description: diff --git a/packages/pieces/community/reoon-verifier/src/lib/actions/bulk-email-verification-status.ts b/packages/pieces/community/reoon-verifier/src/lib/actions/bulk-email-verification-status.ts index 23caf3424106..301ca84da694 100644 --- a/packages/pieces/community/reoon-verifier/src/lib/actions/bulk-email-verification-status.ts +++ b/packages/pieces/community/reoon-verifier/src/lib/actions/bulk-email-verification-status.ts @@ -6,6 +6,7 @@ import { getBulkEmailVerificationResultActionOutputSchema } from '../output-sche export const bulkVerificationResult = createAction({ auth: reoonEmailVerifyAuth, name: 'bulkVerificationResult', + classification: 'READ', displayName: 'Get Bulk Verification Result', description: 'Retrieves result of bulk verification email by task ID.', audience: 'human', diff --git a/packages/pieces/community/reoon-verifier/src/lib/actions/bulk-email-verification.ts b/packages/pieces/community/reoon-verifier/src/lib/actions/bulk-email-verification.ts index 0343ad158aea..2ff64ffa278f 100644 --- a/packages/pieces/community/reoon-verifier/src/lib/actions/bulk-email-verification.ts +++ b/packages/pieces/community/reoon-verifier/src/lib/actions/bulk-email-verification.ts @@ -6,6 +6,7 @@ import { createBulkEmailVerificationActionOutputSchema } from '../output-schemas export const bulkEmailVerification = createAction({ auth: reoonEmailVerifyAuth, name: 'bulkEmailVerificationTask', + classification: 'WRITE', displayName: 'Create Bulk Email Verification', description: 'Creates bulk email verification task.', audience: 'human', diff --git a/packages/pieces/community/reoon-verifier/src/lib/actions/create-bulk-email-verification.ts b/packages/pieces/community/reoon-verifier/src/lib/actions/create-bulk-email-verification.ts index 17301fc4ad66..ae0744dcedea 100644 --- a/packages/pieces/community/reoon-verifier/src/lib/actions/create-bulk-email-verification.ts +++ b/packages/pieces/community/reoon-verifier/src/lib/actions/create-bulk-email-verification.ts @@ -6,6 +6,7 @@ import { createBulkEmailVerificationActionOutputSchema } from '../output-schemas export const createBulkEmailVerification = createAction({ auth: reoonEmailVerifyAuth, name: 'create_bulk_email_verification', + classification: 'WRITE', displayName: 'Create Bulk Email Verification', description: 'Creates an asynchronous bulk email verification task.', audience: 'ai', diff --git a/packages/pieces/community/reoon-verifier/src/lib/actions/get-bulk-email-verification-result.ts b/packages/pieces/community/reoon-verifier/src/lib/actions/get-bulk-email-verification-result.ts index bd7e01fcb65a..f3be4d57e581 100644 --- a/packages/pieces/community/reoon-verifier/src/lib/actions/get-bulk-email-verification-result.ts +++ b/packages/pieces/community/reoon-verifier/src/lib/actions/get-bulk-email-verification-result.ts @@ -6,6 +6,7 @@ import { getBulkEmailVerificationResultActionOutputSchema } from '../output-sche export const getBulkEmailVerificationResult = createAction({ auth: reoonEmailVerifyAuth, name: 'get_bulk_email_verification_result', + classification: 'READ', displayName: 'Get Bulk Email Verification Result', description: 'Reads the status and results of a bulk verification task.', audience: 'ai', diff --git a/packages/pieces/community/reoon-verifier/src/lib/actions/verify-email-address.ts b/packages/pieces/community/reoon-verifier/src/lib/actions/verify-email-address.ts index e212bb9ab7c3..67b91e380af9 100644 --- a/packages/pieces/community/reoon-verifier/src/lib/actions/verify-email-address.ts +++ b/packages/pieces/community/reoon-verifier/src/lib/actions/verify-email-address.ts @@ -6,6 +6,7 @@ import { verifyEmailActionOutputSchema } from '../output-schemas'; export const verifyEmailAddress = createAction({ auth: reoonEmailVerifyAuth, name: 'verify_email_address', + classification: 'READ', displayName: 'Verify Email Address', description: 'Verify a single email address synchronously.', audience: 'ai', diff --git a/packages/pieces/community/reoon-verifier/src/lib/actions/verify-email.ts b/packages/pieces/community/reoon-verifier/src/lib/actions/verify-email.ts index 75a84f3cb4fb..f994d688a598 100644 --- a/packages/pieces/community/reoon-verifier/src/lib/actions/verify-email.ts +++ b/packages/pieces/community/reoon-verifier/src/lib/actions/verify-email.ts @@ -6,6 +6,7 @@ import { verifyEmailActionOutputSchema } from '../output-schemas'; export const verifyEmail = createAction({ auth: reoonEmailVerifyAuth, name: 'verifyEmail', + classification: 'READ', displayName: 'Verify Email', description: 'Verify a single email', audience: 'human', From ba93a93736eddf64ab30b5ef8667d14595006085 Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:31:21 +0530 Subject: [PATCH 4/8] feat(nocodb): add AI actions and output schemas (#15865) --- packages/pieces/community/nocodb/package.json | 2 +- packages/pieces/community/nocodb/src/index.ts | 26 +- .../src/lib/actions/create-grid-view.ts | 34 +++ .../nocodb/src/lib/actions/create-record.ts | 3 + .../nocodb/src/lib/actions/delete-record.ts | 3 + .../nocodb/src/lib/actions/delete-view.ts | 30 ++ .../src/lib/actions/get-current-user.ts | 30 ++ .../nocodb/src/lib/actions/get-record.ts | 3 + .../actions/get-shared-view-grouped-data.ts | 41 +++ .../src/lib/actions/get-table-schema.ts | 37 +++ .../nocodb/src/lib/actions/list-bases.ts | 31 ++ .../src/lib/actions/list-notifications.ts | 41 +++ .../src/lib/actions/list-view-columns.ts | 30 ++ .../nocodb/src/lib/actions/list-view-sorts.ts | 30 ++ .../nocodb/src/lib/actions/list-workspaces.ts | 43 +++ .../nocodb/src/lib/actions/search-records.ts | 3 + .../nocodb/src/lib/actions/update-record.ts | 3 + .../src/lib/actions/update-view-column.ts | 43 +++ .../lib/actions/upload-attachment-by-url.ts | 44 +++ .../community/nocodb/src/lib/common/client.ts | 97 ++++++ .../community/nocodb/src/lib/common/types.ts | 57 ++++ .../nocodb/src/lib/output-schemas.ts | 275 ++++++++++++++++++ 22 files changed, 904 insertions(+), 2 deletions(-) create mode 100644 packages/pieces/community/nocodb/src/lib/actions/create-grid-view.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/delete-view.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/get-current-user.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/get-shared-view-grouped-data.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/get-table-schema.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/list-bases.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/list-notifications.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/list-view-columns.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/list-view-sorts.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/list-workspaces.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/update-view-column.ts create mode 100644 packages/pieces/community/nocodb/src/lib/actions/upload-attachment-by-url.ts create mode 100644 packages/pieces/community/nocodb/src/lib/output-schemas.ts diff --git a/packages/pieces/community/nocodb/package.json b/packages/pieces/community/nocodb/package.json index 60468100fe3b..c91a279a7a84 100644 --- a/packages/pieces/community/nocodb/package.json +++ b/packages/pieces/community/nocodb/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-nocodb", - "version": "0.4.7", + "version": "0.5.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/nocodb/src/index.ts b/packages/pieces/community/nocodb/src/index.ts index 4b8a4d8271d0..cf6a124fee33 100644 --- a/packages/pieces/community/nocodb/src/index.ts +++ b/packages/pieces/community/nocodb/src/index.ts @@ -4,12 +4,24 @@ import { deleteRecordAction } from './lib/actions/delete-record'; import { updateRecordAction } from './lib/actions/update-record'; import { getRecordAction } from './lib/actions/get-record'; import { searchRecordsAction } from './lib/actions/search-records'; +import { getCurrentUserAction } from './lib/actions/get-current-user'; +import { listWorkspacesAction } from './lib/actions/list-workspaces'; +import { listBasesAction } from './lib/actions/list-bases'; +import { getTableSchemaAction } from './lib/actions/get-table-schema'; +import { listViewSortsAction } from './lib/actions/list-view-sorts'; +import { createGridViewAction } from './lib/actions/create-grid-view'; +import { listViewColumnsAction } from './lib/actions/list-view-columns'; +import { updateViewColumnAction } from './lib/actions/update-view-column'; +import { deleteViewAction } from './lib/actions/delete-view'; +import { listNotificationsAction } from './lib/actions/list-notifications'; +import { getSharedViewGroupedDataAction } from './lib/actions/get-shared-view-grouped-data'; +import { uploadAttachmentByUrlAction } from './lib/actions/upload-attachment-by-url'; import { nocodbAuth } from './lib/auth'; export const nocodb = createPiece({ displayName: 'NocoDB', auth: nocodbAuth, - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/nocodb.png', authors: ['kishanprmr'], actions: [ @@ -18,6 +30,18 @@ export const nocodb = createPiece({ updateRecordAction, getRecordAction, searchRecordsAction, + getCurrentUserAction, + listWorkspacesAction, + listBasesAction, + getTableSchemaAction, + listViewSortsAction, + createGridViewAction, + listViewColumnsAction, + updateViewColumnAction, + deleteViewAction, + listNotificationsAction, + getSharedViewGroupedDataAction, + uploadAttachmentByUrlAction, ], triggers: [], }); diff --git a/packages/pieces/community/nocodb/src/lib/actions/create-grid-view.ts b/packages/pieces/community/nocodb/src/lib/actions/create-grid-view.ts new file mode 100644 index 000000000000..256c2a88a0fb --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/create-grid-view.ts @@ -0,0 +1,34 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbCreateGridViewOutputSchema } from '../output-schemas'; + +export const createGridViewAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-create-grid-view', + outputSchema: nocodbCreateGridViewOutputSchema, + classification: 'WRITE', + displayName: 'Create Grid View', + description: 'Creates a new grid view on the given table.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new grid view on a table with the given title. Use when an agent needs a dedicated view to configure (columns, sort) separately from the table\'s default view. Not idempotent: each call creates another view.', + idempotent: false, + }, + props: { + tableId: Property.ShortText({ + displayName: 'Table ID', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + required: true, + }), + }, + async run(context) { + const { tableId, title } = context.propsValue; + const client = makeClient(context.auth); + return await client.createGridView(tableId, title); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/create-record.ts b/packages/pieces/community/nocodb/src/lib/actions/create-record.ts index 077028df3b26..a16507116d35 100644 --- a/packages/pieces/community/nocodb/src/lib/actions/create-record.ts +++ b/packages/pieces/community/nocodb/src/lib/actions/create-record.ts @@ -1,10 +1,13 @@ import { nocodbAuth } from '../auth'; import { createAction, DynamicPropsValue } from '@activepieces/pieces-framework'; import { makeClient, nocodbCommon } from '../common'; +import { nocodbCreateRecordOutputSchema } from '../output-schemas'; export const createRecordAction = createAction({ auth: nocodbAuth, name: 'nocodb-create-record', + classification: 'WRITE', + outputSchema: nocodbCreateRecordOutputSchema, displayName: 'Create a Record', description: 'Creates a new record in the given table.', audience: 'both', diff --git a/packages/pieces/community/nocodb/src/lib/actions/delete-record.ts b/packages/pieces/community/nocodb/src/lib/actions/delete-record.ts index f52d8dd297b3..19edd474026b 100644 --- a/packages/pieces/community/nocodb/src/lib/actions/delete-record.ts +++ b/packages/pieces/community/nocodb/src/lib/actions/delete-record.ts @@ -1,10 +1,13 @@ import { nocodbAuth } from '../auth'; import { createAction, Property } from '@activepieces/pieces-framework'; import { makeClient, nocodbCommon } from '../common'; +import { nocodbCreateRecordOutputSchema } from '../output-schemas'; export const deleteRecordAction = createAction({ auth: nocodbAuth, name: 'nocodb-delete-record', + classification: 'DESTRUCTIVE', + outputSchema: nocodbCreateRecordOutputSchema, displayName: 'Delete a Record', description: 'Deletes a record with the given Record ID.', audience: 'both', diff --git a/packages/pieces/community/nocodb/src/lib/actions/delete-view.ts b/packages/pieces/community/nocodb/src/lib/actions/delete-view.ts new file mode 100644 index 000000000000..bc0cea573fde --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/delete-view.ts @@ -0,0 +1,30 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbResultOutputSchema } from '../output-schemas'; + +export const deleteViewAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-delete-view', + outputSchema: nocodbResultOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Delete View', + description: 'Deletes the given view.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a view by its ID. Only removes the view itself, not the underlying table or its records. Not idempotent: deleting an already-deleted view fails.', + idempotent: false, + }, + props: { + viewId: Property.ShortText({ + displayName: 'View ID', + required: true, + }), + }, + async run(context) { + const { viewId } = context.propsValue; + const client = makeClient(context.auth); + return await client.deleteView(viewId); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/get-current-user.ts b/packages/pieces/community/nocodb/src/lib/actions/get-current-user.ts new file mode 100644 index 000000000000..e4c48b3bc393 --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/get-current-user.ts @@ -0,0 +1,30 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbGetCurrentUserOutputSchema } from '../output-schemas'; + +export const getCurrentUserAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-get-current-user', + outputSchema: nocodbGetCurrentUserOutputSchema, + classification: 'READ', + displayName: 'Get Current User', + description: 'Returns the profile and roles of the authenticated user.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the identity, email, and roles of the account the connection belongs to, optionally scoped to a base. Use to confirm which account is authenticated or to check its role before a write. Idempotent read-only lookup.', + idempotent: true, + }, + props: { + baseId: Property.ShortText({ + displayName: 'Base ID', + required: false, + }), + }, + async run(context) { + const { baseId } = context.propsValue; + const client = makeClient(context.auth); + return await client.getCurrentUser(baseId); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/get-record.ts b/packages/pieces/community/nocodb/src/lib/actions/get-record.ts index 9c486e7626df..5d6ce30d89af 100644 --- a/packages/pieces/community/nocodb/src/lib/actions/get-record.ts +++ b/packages/pieces/community/nocodb/src/lib/actions/get-record.ts @@ -1,10 +1,13 @@ import { nocodbAuth } from '../auth'; import { createAction, Property } from '@activepieces/pieces-framework'; import { makeClient, nocodbCommon } from '../common'; +import { nocodbGetRecordOutputSchema } from '../output-schemas'; export const getRecordAction = createAction({ auth: nocodbAuth, name: 'nocodb-get-record', + classification: 'READ', + outputSchema: nocodbGetRecordOutputSchema, displayName: 'Get a Record', description: 'Gets a record by the Record ID.', audience: 'both', diff --git a/packages/pieces/community/nocodb/src/lib/actions/get-shared-view-grouped-data.ts b/packages/pieces/community/nocodb/src/lib/actions/get-shared-view-grouped-data.ts new file mode 100644 index 000000000000..4192aab86ba8 --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/get-shared-view-grouped-data.ts @@ -0,0 +1,41 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbGetSharedViewGroupedDataOutputSchema } from '../output-schemas'; + +export const getSharedViewGroupedDataAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-get-shared-view-grouped-data', + outputSchema: nocodbGetSharedViewGroupedDataOutputSchema, + classification: 'SEARCH', + displayName: 'Get Shared View Grouped Data', + description: 'Returns the records of a public shared view, grouped by the given column.', + audience: 'ai', + aiMetadata: { + description: + 'Reads a public shared-view link\'s records grouped by a column, without requiring an authenticated connection to the base. Needs the shared view\'s UUID (from the view\'s share link) and the column ID to group by; pass the share password if the link is password-protected. Idempotent read-only query.', + idempotent: true, + }, + props: { + sharedViewUuid: Property.ShortText({ + displayName: 'Shared View UUID', + description: 'The UUID from the view\'s public share link.', + required: true, + }), + columnId: Property.ShortText({ + displayName: 'Column ID', + description: 'The table column ID to group records by.', + required: true, + }), + password: Property.ShortText({ + displayName: 'Share Password', + description: 'Only required if the shared view link is password-protected.', + required: false, + }), + }, + async run(context) { + const { sharedViewUuid, columnId, password } = context.propsValue; + const client = makeClient(context.auth); + return await client.getSharedViewGroupedData(sharedViewUuid, columnId, password); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/get-table-schema.ts b/packages/pieces/community/nocodb/src/lib/actions/get-table-schema.ts new file mode 100644 index 000000000000..0d37fb92cf07 --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/get-table-schema.ts @@ -0,0 +1,37 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbGetTableSchemaOutputSchema } from '../output-schemas'; + +export const getTableSchemaAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-get-table-schema', + outputSchema: nocodbGetTableSchemaOutputSchema, + classification: 'READ', + displayName: 'Get Table Schema', + description: 'Returns the columns and metadata of the given table.', + audience: 'ai', + aiMetadata: { + description: + 'Returns a table\'s columns (name, id, type, and select options) and metadata. Use before creating or updating a record to see which fields exist and what values they accept, or to resolve a column ID for the view-column actions. Idempotent read-only lookup.', + idempotent: true, + }, + props: { + baseId: Property.ShortText({ + displayName: 'Base ID', + required: true, + }), + tableId: Property.ShortText({ + displayName: 'Table ID', + required: true, + }), + }, + async run(context) { + const { baseId, tableId } = context.propsValue; + const client = makeClient(context.auth); + const version = context.auth.props.version || 3; + return version === 4 + ? await client.getTableV3(baseId, tableId, version) + : await client.getTable(baseId, tableId, version); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/list-bases.ts b/packages/pieces/community/nocodb/src/lib/actions/list-bases.ts new file mode 100644 index 000000000000..3be5c16c9b58 --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/list-bases.ts @@ -0,0 +1,31 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbListBasesOutputSchema } from '../output-schemas'; + +export const listBasesAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-list-bases', + outputSchema: nocodbListBasesOutputSchema, + classification: 'READ', + displayName: 'List Bases', + description: 'Returns the bases within the given workspace (or the instance, on self-hosted).', + audience: 'ai', + aiMetadata: { + description: + 'Lists bases (projects), used to resolve a Base ID before listing its tables. Pass a workspace ID from List Workspaces, or leave it unset on self-hosted instances. Idempotent read-only query.', + idempotent: true, + }, + props: { + workspaceId: Property.ShortText({ + displayName: 'Workspace ID', + description: 'Leave unset on self-hosted instances.', + required: false, + }), + }, + async run(context) { + const { workspaceId } = context.propsValue; + const client = makeClient(context.auth); + return await client.listBases(workspaceId, context.auth.props.version || 3); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/list-notifications.ts b/packages/pieces/community/nocodb/src/lib/actions/list-notifications.ts new file mode 100644 index 000000000000..9478d7f09aac --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/list-notifications.ts @@ -0,0 +1,41 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbListNotificationsOutputSchema } from '../output-schemas'; + +export const listNotificationsAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-list-notifications', + outputSchema: nocodbListNotificationsOutputSchema, + classification: 'SEARCH', + displayName: 'List Notifications', + description: 'Returns the authenticated user\'s notifications.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the authenticated account\'s notifications. Leave Read Only unset to return all notifications regardless of read state, or check it to return only already-read ones; there is no unread-only filter. Use to check for recent activity across bases the account has access to. Idempotent read-only query.', + idempotent: true, + }, + props: { + isRead: Property.Checkbox({ + displayName: 'Read Only', + description: 'When checked, returns only already-read notifications. Leave unchecked to return all notifications (read and unread) — there is no unread-only filter.', + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + required: false, + defaultValue: 25, + }), + offset: Property.Number({ + displayName: 'Offset', + required: false, + defaultValue: 0, + }), + }, + async run(context) { + const { isRead, limit, offset } = context.propsValue; + const client = makeClient(context.auth); + return await client.listNotifications(isRead, limit, offset); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/list-view-columns.ts b/packages/pieces/community/nocodb/src/lib/actions/list-view-columns.ts new file mode 100644 index 000000000000..7772674f3cb1 --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/list-view-columns.ts @@ -0,0 +1,30 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbListViewColumnsOutputSchema } from '../output-schemas'; + +export const listViewColumnsAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-list-view-columns', + outputSchema: nocodbListViewColumnsOutputSchema, + classification: 'READ', + displayName: 'List View Columns', + description: 'Returns the columns configured on the given view, with their view-scoped column IDs.', + audience: 'ai', + aiMetadata: { + description: + 'Lists a view\'s columns along with their visibility, order, and view-scoped column ID. That ID is required by Update View Column and is different from the table column ID returned by Get Table Schema. Idempotent read-only query.', + idempotent: true, + }, + props: { + viewId: Property.ShortText({ + displayName: 'View ID', + required: true, + }), + }, + async run(context) { + const { viewId } = context.propsValue; + const client = makeClient(context.auth); + return await client.listViewColumns(viewId); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/list-view-sorts.ts b/packages/pieces/community/nocodb/src/lib/actions/list-view-sorts.ts new file mode 100644 index 000000000000..7e211fa243b9 --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/list-view-sorts.ts @@ -0,0 +1,30 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbListViewSortsOutputSchema } from '../output-schemas'; + +export const listViewSortsAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-list-view-sorts', + outputSchema: nocodbListViewSortsOutputSchema, + classification: 'READ', + displayName: 'List View Sorts', + description: 'Returns the sort rules configured on the given view.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the field/direction sort rules applied to a view. Use to inspect a view\'s current ordering before changing it. Idempotent read-only query.', + idempotent: true, + }, + props: { + viewId: Property.ShortText({ + displayName: 'View ID', + required: true, + }), + }, + async run(context) { + const { viewId } = context.propsValue; + const client = makeClient(context.auth); + return await client.listViewSorts(viewId); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/list-workspaces.ts b/packages/pieces/community/nocodb/src/lib/actions/list-workspaces.ts new file mode 100644 index 000000000000..2b729bdc9ce7 --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/list-workspaces.ts @@ -0,0 +1,43 @@ +import { nocodbAuth } from '../auth'; +import { createAction } from '@activepieces/pieces-framework'; +import { HttpError } from '@activepieces/pieces-common'; +import { makeClient } from '../common'; +import { ListAPIResponse, WorkspaceResponse } from '../common/types'; +import { nocodbListWorkspacesOutputSchema } from '../output-schemas'; + +export const listWorkspacesAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-list-workspaces', + outputSchema: nocodbListWorkspacesOutputSchema, + classification: 'READ', + displayName: 'List Workspaces', + description: 'Returns the workspaces visible to the authenticated account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the Cloud/Enterprise workspaces the account belongs to, used to resolve a workspace ID before listing its bases. On self-hosted Community Edition, workspaces do not exist and this returns an empty list rather than an error. Idempotent read-only query.', + idempotent: true, + }, + props: {}, + async run(context) { + const client = makeClient(context.auth); + try { + return await client.listWorkspaces(); + } catch (error) { + if (error instanceof HttpError && error.response.status === 404) { + const emptyList: ListAPIResponse = { + list: [], + pageInfo: { + totalRows: 0, + page: 1, + pageSize: 0, + isFirstPage: true, + isLastPage: true, + }, + }; + return emptyList; + } + throw error; + } + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/search-records.ts b/packages/pieces/community/nocodb/src/lib/actions/search-records.ts index ca43aa804be0..0f6f5292bae7 100644 --- a/packages/pieces/community/nocodb/src/lib/actions/search-records.ts +++ b/packages/pieces/community/nocodb/src/lib/actions/search-records.ts @@ -2,10 +2,13 @@ import { nocodbAuth } from '../auth'; import { createAction, Property } from '@activepieces/pieces-framework'; import { makeClient, nocodbCommon } from '../common'; import { ListAPIResponse, ListAPIV3Response } from '../common/types'; +import { nocodbSearchRecordsOutputSchema } from '../output-schemas'; export const searchRecordsAction = createAction({ auth: nocodbAuth, name: 'nocodb-search-records', + classification: 'SEARCH', + outputSchema: nocodbSearchRecordsOutputSchema, displayName: 'Search Records', description: 'Returns a list of records matching the where condition.', audience: 'both', diff --git a/packages/pieces/community/nocodb/src/lib/actions/update-record.ts b/packages/pieces/community/nocodb/src/lib/actions/update-record.ts index 613219786fa1..d425fef4fec3 100644 --- a/packages/pieces/community/nocodb/src/lib/actions/update-record.ts +++ b/packages/pieces/community/nocodb/src/lib/actions/update-record.ts @@ -5,10 +5,13 @@ import { Property, } from '@activepieces/pieces-framework'; import { makeClient, nocodbCommon } from '../common'; +import { nocodbCreateRecordOutputSchema } from '../output-schemas'; export const updateRecordAction = createAction({ auth: nocodbAuth, name: 'nocodb-update-record', + classification: 'WRITE', + outputSchema: nocodbCreateRecordOutputSchema, displayName: 'Update a Record', description: 'Updates an existing record with the given Record ID.', audience: 'both', diff --git a/packages/pieces/community/nocodb/src/lib/actions/update-view-column.ts b/packages/pieces/community/nocodb/src/lib/actions/update-view-column.ts new file mode 100644 index 000000000000..4314f7558e17 --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/update-view-column.ts @@ -0,0 +1,43 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbResultOutputSchema } from '../output-schemas'; + +export const updateViewColumnAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-update-view-column', + outputSchema: nocodbResultOutputSchema, + classification: 'WRITE', + displayName: 'Update View Column', + description: 'Shows, hides, or reorders a column within a view.', + audience: 'ai', + aiMetadata: { + description: + 'Updates a column\'s visibility and/or order within a specific view, identified by the view-scoped column ID from List View Columns (not the table column ID). Use to curate what an agent or user sees in a view. Idempotent: re-applying the same show/order state yields the same result.', + idempotent: true, + }, + props: { + viewId: Property.ShortText({ + displayName: 'View ID', + required: true, + }), + columnId: Property.ShortText({ + displayName: 'View Column ID', + description: 'The view-scoped column ID from List View Columns, not the table column ID.', + required: true, + }), + show: Property.Checkbox({ + displayName: 'Show', + required: false, + }), + order: Property.Number({ + displayName: 'Order', + required: false, + }), + }, + async run(context) { + const { viewId, columnId, show, order } = context.propsValue; + const client = makeClient(context.auth); + return await client.updateViewColumn(viewId, columnId, { show, order }); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/actions/upload-attachment-by-url.ts b/packages/pieces/community/nocodb/src/lib/actions/upload-attachment-by-url.ts new file mode 100644 index 000000000000..dbd284f4b233 --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/actions/upload-attachment-by-url.ts @@ -0,0 +1,44 @@ +import { nocodbAuth } from '../auth'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { nocodbUploadAttachmentByUrlOutputSchema } from '../output-schemas'; + +export const uploadAttachmentByUrlAction = createAction({ + auth: nocodbAuth, + name: 'nocodb-upload-attachment-by-url', + outputSchema: nocodbUploadAttachmentByUrlOutputSchema, + classification: 'WRITE', + displayName: 'Upload Attachment By URL', + description: 'Uploads a file to NocoDB storage from a public URL, for use in an attachment column.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches a file from a public URL and stores it in NocoDB storage, returning the attachment metadata to place into an attachment column value on a subsequent create/update record call. Not idempotent: each call stores another copy of the file.', + idempotent: false, + }, + props: { + url: Property.ShortText({ + displayName: 'File URL', + required: true, + }), + path: Property.ShortText({ + displayName: 'Target Path', + description: 'The storage path to upload to, e.g. download/noco///attachment.', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + description: 'Display name for the attachment.', + required: false, + }), + mimetype: Property.ShortText({ + displayName: 'MIME Type', + required: false, + }), + }, + async run(context) { + const { url, path, title, mimetype } = context.propsValue; + const client = makeClient(context.auth); + return await client.uploadAttachmentByUrl(path, { url, title, mimetype }); + }, +}); diff --git a/packages/pieces/community/nocodb/src/lib/common/client.ts b/packages/pieces/community/nocodb/src/lib/common/client.ts index d53cb8059ab8..1e4b5ac3d13b 100644 --- a/packages/pieces/community/nocodb/src/lib/common/client.ts +++ b/packages/pieces/community/nocodb/src/lib/common/client.ts @@ -6,7 +6,9 @@ import { HttpRequest, } from '@activepieces/pieces-common'; import { + AttachmentUploadReq, BaseResponse, + CurrentUserResponse, DataOperationResponse, DataOperationV3Response, GetTableResponse, @@ -14,7 +16,12 @@ import { ListAPIResponse, ListAPIV3Response, ListRecordsParams, + NotificationResponse, + SharedViewGroupedDataItem, TableResponse, + ViewColumnResponse, + ViewResponse, + ViewSortResponse, WorkspaceResponse, } from './types'; @@ -263,4 +270,94 @@ export class NocoDBClient { return makeRequest>>(); } } + + async getCurrentUser(baseId?: string): Promise { + return await this.makeRequest( + HttpMethod.GET, + '/v1/auth/user/me', + baseId ? { base_id: baseId } : undefined + ); + } + + async listNotifications( + isRead?: boolean, + limit?: number, + offset?: number + ): Promise> { + return await this.makeRequest>( + HttpMethod.GET, + '/v1/notifications', + { is_read: isRead === undefined ? undefined : String(isRead), limit, offset } + ); + } + + async listViewSorts(viewId: string): Promise> { + return await this.makeRequest>( + HttpMethod.GET, + `/v1/db/meta/views/${viewId}/sorts` + ); + } + + async listViewColumns(viewId: string): Promise> { + return await this.makeRequest>( + HttpMethod.GET, + `/v1/db/meta/views/${viewId}/columns` + ); + } + + async updateViewColumn( + viewId: string, + columnId: string, + input: { show?: boolean; order?: number } + ) { + return await this.makeRequest( + HttpMethod.PATCH, + `/v1/db/meta/views/${viewId}/columns/${columnId}`, + undefined, + input + ); + } + + async createGridView(tableId: string, title: string): Promise { + return await this.makeRequest( + HttpMethod.POST, + `/v1/db/meta/tables/${tableId}/grids`, + undefined, + { title } + ); + } + + async deleteView(viewId: string) { + return await this.makeRequest(HttpMethod.DELETE, `/v1/db/meta/views/${viewId}`); + } + + async getSharedViewGroupedData( + sharedViewUuid: string, + columnId: string, + password?: string + ): Promise { + const baseUrl = this.hostUrl.replace(/\/$/, ''); + const request: HttpRequest = { + method: HttpMethod.GET, + url: `${baseUrl}/api/v1/db/public/shared-view/${sharedViewUuid}/group/${columnId}`, + headers: password ? { 'xc-password': password } : undefined, + }; + const response = await httpClient.sendRequest(request); + return response.body; + } + + async uploadAttachmentByUrl(path: string, attachment: AttachmentUploadReq) { + const baseUrl = this.hostUrl.replace(/\/$/, ''); + const request: HttpRequest = { + method: HttpMethod.POST, + url: `${baseUrl}/api/v1/db/storage/upload-by-url`, + headers: { + 'xc-token': this.apiToken, + }, + queryParams: { path }, + body: [attachment], + }; + const response = await httpClient.sendRequest(request); + return response.body; + } } diff --git a/packages/pieces/community/nocodb/src/lib/common/types.ts b/packages/pieces/community/nocodb/src/lib/common/types.ts index 6343ada94377..c8451fbe71a1 100644 --- a/packages/pieces/community/nocodb/src/lib/common/types.ts +++ b/packages/pieces/community/nocodb/src/lib/common/types.ts @@ -148,3 +148,60 @@ export interface DataOperationV3Response { export type DataOperationResponse = | Record | DataOperationV3Response; + +export interface CurrentUserResponse { + id: string; + email: string; + email_verified: boolean; + firstname: string | null; + lastname: string | null; + roles: Record; + base_roles?: Record; + workspace_roles?: Record; +} + +export interface NotificationResponse { + id: string; + type: string; + body: Record; + is_read: boolean; + is_deleted: boolean; + created_at: string; + updated_at: string; +} + +export interface ViewSortResponse { + id: string; + fk_view_id: string; + fk_column_id: string; + direction: string; +} + +export interface ViewColumnResponse { + id: string; + fk_view_id: string; + fk_column_id: string; + show: boolean; + order: number; +} + +export interface ViewResponse { + id: string; + title: string; + type: number; + fk_model_id: string; + base_id: string; + uuid: string | null; + show: boolean; +} + +export interface SharedViewGroupedDataItem { + key: string; + value: Record[]; +} + +export interface AttachmentUploadReq { + url: string; + title?: string; + mimetype?: string; +} diff --git a/packages/pieces/community/nocodb/src/lib/output-schemas.ts b/packages/pieces/community/nocodb/src/lib/output-schemas.ts new file mode 100644 index 000000000000..88219cafeee9 --- /dev/null +++ b/packages/pieces/community/nocodb/src/lib/output-schemas.ts @@ -0,0 +1,275 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const nocodbPageInfoFields: OutputSchema['fields'] = [ + { key: 'totalRows', label: 'Total Rows', format: 'number' }, + { key: 'page', label: 'Page', format: 'number' }, + { key: 'pageSize', label: 'Page Size', format: 'number' }, + { key: 'isFirstPage', label: 'Is First Page', format: 'boolean' }, + { key: 'isLastPage', label: 'Is Last Page', format: 'boolean' }, +]; + +const nocodbViewFields: OutputSchema['fields'] = [ + { key: 'id', label: 'View ID' }, + { key: 'title', label: 'Title' }, + { key: 'type', label: 'Type', format: 'number' }, + { key: 'is_default', label: 'Is Default', format: 'boolean' }, + { key: 'show_system_fields', label: 'Show System Fields', format: 'boolean' }, + { key: 'order', label: 'Order', format: 'number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + { key: 'description', label: 'Description' }, +]; + +const nocodbColumnFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Column ID' }, + { key: 'title', label: 'Title' }, + { key: 'column_name', label: 'Column Name' }, + { key: 'uidt', label: 'UI Data Type' }, + { key: 'pk', label: 'Primary Key', format: 'boolean' }, + { key: 'pv', label: 'Primary Value', format: 'boolean' }, + { key: 'rqd', label: 'Required', format: 'boolean' }, + { key: 'system', label: 'System', format: 'boolean' }, + { key: 'readonly', label: 'Readonly', format: 'boolean' }, + { key: 'order', label: 'Order', format: 'number' }, + { key: 'description', label: 'Description' }, +]; + +const nocodbColumnV3Fields: OutputSchema['fields'] = [ + { key: 'id', label: 'Column ID' }, + { key: 'title', label: 'Title' }, + { key: 'type', label: 'Type' }, +]; + +const nocodbRecordV4Fields: OutputSchema['fields'] = [ + { key: 'id', label: 'ID (API v4)' }, + { key: 'fields', label: 'Fields (API v4)', dynamicKey: true }, +]; + +export const nocodbCreateGridViewOutputSchema: OutputSchema = { + fields: [ + { key: 'base_id', label: 'Base ID' }, + { key: 'fk_model_id', label: 'Table ID' }, + ...nocodbViewFields, + ], +}; + +export const nocodbResultOutputSchema: OutputSchema = { + fields: [{ key: 'result', label: 'Result', value: '' }], +}; + +export const nocodbGetCurrentUserOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'display_name', label: 'Display Name' }, + { key: 'is_api_token', label: 'Is API Token', format: 'boolean' }, + { key: 'is_new_user', label: 'Is New User', format: 'boolean' }, + { key: 'isAuthorized', label: 'Is Authorized', format: 'boolean' }, + { key: 'roles', label: 'Roles', dynamicKey: true }, + ], +}; + +export const nocodbGetRecordOutputSchema: OutputSchema = { + fields: [ + { key: 'Id', label: 'ID', format: 'number' }, + { key: 'Title', label: 'Title' }, + { key: 'CreatedAt', label: 'Created At', format: 'datetime' }, + { key: 'UpdatedAt', label: 'Updated At', format: 'datetime' }, + ...nocodbRecordV4Fields, + ], +}; + +export const nocodbCreateRecordOutputSchema: OutputSchema = { + fields: [{ key: 'Id', label: 'ID', format: 'number' }, ...nocodbRecordV4Fields], +}; + +export const nocodbGetTableSchemaOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Table ID' }, + { key: 'base_id', label: 'Base ID' }, + { key: 'table_name', label: 'Table Name' }, + { key: 'title', label: 'Title' }, + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + { key: 'order', label: 'Order', format: 'number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + { key: 'description', label: 'Description' }, + { + key: 'views', + label: 'Views', + labelKey: 'title', + listItems: nocodbViewFields, + }, + { + key: 'columns', + label: 'Columns', + labelKey: 'title', + listItems: nocodbColumnFields, + }, + { + key: 'fields', + label: 'Fields (API v4)', + labelKey: 'title', + listItems: nocodbColumnV3Fields, + }, + ], +}; + +export const nocodbListBasesOutputSchema: OutputSchema = { + fields: [ + { + key: 'list', + label: 'List', + labelKey: 'title', + listItems: [ + { key: 'id', label: 'ID' }, + { key: 'title', label: 'Title' }, + { key: 'prefix', label: 'Prefix' }, + { key: 'status', label: 'Status' }, + { key: 'description', label: 'Description' }, + { key: 'color', label: 'Color' }, + { key: 'type', label: 'Type' }, + { key: 'default_role', label: 'Default Role' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + { key: 'last_accessed', label: 'Last Accessed', format: 'datetime' }, + ], + }, + { key: 'pageInfo', label: 'Page Info', children: nocodbPageInfoFields }, + ], +}; + +export const nocodbListNotificationsOutputSchema: OutputSchema = { + fields: [ + { + key: 'list', + label: 'List', + labelKey: 'id', + listItems: [ + { key: 'id', label: 'ID' }, + { key: 'type', label: 'Type' }, + { key: 'body', label: 'Body' }, + { key: 'is_read', label: 'Is Read', format: 'boolean' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + ], + }, + { key: 'pageInfo', label: 'Page Info', children: nocodbPageInfoFields }, + { key: 'unreadCount', label: 'Unread Count', format: 'number' }, + ], +}; + +export const nocodbListViewColumnsOutputSchema: OutputSchema = { + fields: [ + { + key: 'list', + label: 'List', + labelKey: 'id', + listItems: [ + { key: 'id', label: 'View Column ID' }, + { key: 'fk_column_id', label: 'Column ID' }, + { key: 'label', label: 'Label' }, + { key: 'show', label: 'Show', format: 'boolean' }, + { key: 'order', label: 'Order', format: 'number' }, + ], + }, + ], +}; + +export const nocodbListViewSortsOutputSchema: OutputSchema = { + fields: [ + { + key: 'list', + label: 'List', + labelKey: 'fk_column_id', + listItems: [ + { key: 'id', label: 'Sort ID' }, + { key: 'fk_column_id', label: 'Column ID' }, + { key: 'direction', label: 'Direction' }, + ], + }, + ], +}; + +export const nocodbListWorkspacesOutputSchema: OutputSchema = { + fields: [ + { + key: 'list', + label: 'List', + labelKey: 'title', + listItems: [ + { key: 'id', label: 'ID' }, + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'status', label: 'Status', format: 'number' }, + ], + }, + { key: 'pageInfo', label: 'Page Info', children: nocodbPageInfoFields }, + ], +}; + +export const nocodbSearchRecordsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Found Records', + value: '', + listItems: [ + { key: 'Id', label: 'ID', format: 'number' }, + { key: 'Title', label: 'Title' }, + { key: 'CreatedAt', label: 'Created At', format: 'datetime' }, + { key: 'UpdatedAt', label: 'Updated At', format: 'datetime' }, + ...nocodbRecordV4Fields, + ], + }, + ], + itemLabel: 'Record {Id}', +}; + +export const nocodbGetSharedViewGroupedDataOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Groups', + value: '', + listItems: [ + { key: 'key', label: 'Key' }, + { + key: 'value', + label: 'Value', + children: [ + { + key: 'list', + label: 'List', + listItems: [ + { key: 'Id', label: 'ID', format: 'number' }, + { key: 'Title', label: 'Title' }, + ], + }, + { key: 'pageInfo', label: 'Page Info', children: nocodbPageInfoFields }, + ], + }, + ], + }, + ], + itemLabel: '{key}', +}; + +export const nocodbUploadAttachmentByUrlOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Uploaded Files', + value: '', + listItems: [ + { key: 'url', label: 'URL', format: 'image' }, + { key: 'title', label: 'Title' }, + { key: 'mimetype', label: 'Mimetype' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'width', label: 'Width', format: 'number' }, + { key: 'height', label: 'Height', format: 'number' }, + { key: 'signedUrl', label: 'Signed URL', format: 'image' }, + ], + }, + ], + itemLabel: '{title}', +}; From 008f9baf7bd19c7f1bea616a6564ccec1ce1312e Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:55:03 +0530 Subject: [PATCH 5/8] feat(tavily): add AI actions and output schemas (#15870) --- packages/pieces/community/tavily/README.md | 10 ++ packages/pieces/community/tavily/package.json | 2 +- packages/pieces/community/tavily/src/index.ts | 16 +- .../src/lib/actions/ai/crawl-website.ts | 128 +++++++++++++++ .../src/lib/actions/ai/get-research-task.ts | 45 ++++++ .../tavily/src/lib/actions/ai/get-usage.ts | 37 +++++ .../tavily/src/lib/actions/ai/map-website.ts | 95 +++++++++++ .../src/lib/actions/ai/start-research-task.ts | 93 +++++++++++ .../tavily/src/lib/actions/extract.ts | 2 + .../tavily/src/lib/actions/search.ts | 2 + .../community/tavily/src/lib/common/client.ts | 52 +++++++ .../tavily/src/lib/output-schemas.ts | 147 ++++++++++++++++++ 12 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 packages/pieces/community/tavily/src/lib/actions/ai/crawl-website.ts create mode 100644 packages/pieces/community/tavily/src/lib/actions/ai/get-research-task.ts create mode 100644 packages/pieces/community/tavily/src/lib/actions/ai/get-usage.ts create mode 100644 packages/pieces/community/tavily/src/lib/actions/ai/map-website.ts create mode 100644 packages/pieces/community/tavily/src/lib/actions/ai/start-research-task.ts create mode 100644 packages/pieces/community/tavily/src/lib/common/client.ts create mode 100644 packages/pieces/community/tavily/src/lib/output-schemas.ts diff --git a/packages/pieces/community/tavily/README.md b/packages/pieces/community/tavily/README.md index e0e46df4bb33..4a2f5862a116 100644 --- a/packages/pieces/community/tavily/README.md +++ b/packages/pieces/community/tavily/README.md @@ -15,6 +15,16 @@ Tavily is an AI-powered search engine designed for accurate and real-time inform - Supports various content types - Removes ads and irrelevant content +3. **Crawl Website**: Crawl a website from a root URL and return page content + +4. **Map Website**: Crawl a website from a root URL and return the URL structure, without page content + +5. **Start Research Task**: Start an asynchronous, in-depth research task on a topic or question + +6. **Get Research Task**: Get the status and result of a research task by its request id + +7. **Get Usage**: Get API key and account credit usage for the connected Tavily account + ## Authentication This piece requires an API key from Tavily. To obtain your API key: 1. Visit https://tavily.com/ and sign up for an account diff --git a/packages/pieces/community/tavily/package.json b/packages/pieces/community/tavily/package.json index f5971e15ab8a..9f7b1fab337c 100644 --- a/packages/pieces/community/tavily/package.json +++ b/packages/pieces/community/tavily/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-tavily", - "version": "0.2.0", + "version": "0.3.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/tavily/src/index.ts b/packages/pieces/community/tavily/src/index.ts index 0eec038b0c82..22b0f1f62919 100644 --- a/packages/pieces/community/tavily/src/index.ts +++ b/packages/pieces/community/tavily/src/index.ts @@ -3,17 +3,29 @@ import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { searchAction } from './lib/actions/search'; import { extractAction } from './lib/actions/extract'; +import { crawlWebsiteAction } from './lib/actions/ai/crawl-website'; +import { mapWebsiteAction } from './lib/actions/ai/map-website'; +import { startResearchTaskAction } from './lib/actions/ai/start-research-task'; +import { getResearchTaskAction } from './lib/actions/ai/get-research-task'; +import { getUsageAction } from './lib/actions/ai/get-usage'; import { tavilyAuth } from './lib/auth'; export const tavily = createPiece({ displayName: 'Tavily', description: 'Search engine tailored for AI agents.', - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/tavily.jpg', categories: [PieceCategory.ARTIFICIAL_INTELLIGENCE], authors: ['OsamaHaikal'], auth: tavilyAuth, - actions: [searchAction, extractAction, + actions: [ + searchAction, + extractAction, + crawlWebsiteAction, + mapWebsiteAction, + startResearchTaskAction, + getResearchTaskAction, + getUsageAction, createCustomApiCallAction({ baseUrl: () => 'https://api.tavily.com', auth: tavilyAuth, diff --git a/packages/pieces/community/tavily/src/lib/actions/ai/crawl-website.ts b/packages/pieces/community/tavily/src/lib/actions/ai/crawl-website.ts new file mode 100644 index 000000000000..a2a5ca183754 --- /dev/null +++ b/packages/pieces/community/tavily/src/lib/actions/ai/crawl-website.ts @@ -0,0 +1,128 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { tavilyAuth } from '../../auth'; +import { tavilyCommon } from '../../common/client'; +import { tavilyCrawlWebsiteOutputSchema } from '../../output-schemas'; + +export const crawlWebsiteAction = createAction({ + name: 'tavily_crawl_website', + outputSchema: tavilyCrawlWebsiteOutputSchema, + classification: 'SEARCH', + displayName: 'Crawl Website', + description: 'Crawl a website starting from a root URL and return the content of the pages found.', + audience: 'ai', + aiMetadata: { + description: + "Explore a website starting from a root URL, following links up to a depth and breadth limit, and return the extracted content of each page visited. Use to gather content across a whole site or section rather than a single page. Use `tavily_map_website` instead when only the URL structure is needed, not page content. Not idempotent: page content and the set of URLs discovered can change between runs.", + idempotent: false, + }, + auth: tavilyAuth, + props: { + url: Property.ShortText({ + displayName: 'Root URL', + description: 'The root URL to begin the crawl from.', + required: true, + }), + instructions: Property.LongText({ + displayName: 'Instructions', + description: 'Natural language instructions describing what the crawler should look for.', + required: false, + }), + max_depth: Property.Number({ + displayName: 'Max Depth', + description: 'How far from the root URL the crawler can explore. Range: 1-5.', + required: false, + defaultValue: 1, + }), + max_breadth: Property.Number({ + displayName: 'Max Breadth', + description: 'Maximum number of links to follow per level of the tree. Range: 1-500.', + required: false, + defaultValue: 20, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Total number of links the crawler will process before stopping.', + required: false, + defaultValue: 50, + }), + select_paths: Property.Array({ + displayName: 'Select Paths', + description: 'Regex patterns selecting only URLs with these path patterns.', + required: false, + }), + select_domains: Property.Array({ + displayName: 'Select Domains', + description: 'Regex patterns selecting only specific domains or subdomains.', + required: false, + }), + exclude_paths: Property.Array({ + displayName: 'Exclude Paths', + description: 'Regex patterns excluding URLs with these path patterns.', + required: false, + }), + exclude_domains: Property.Array({ + displayName: 'Exclude Domains', + description: 'Regex patterns excluding specific domains or subdomains.', + required: false, + }), + allow_external: Property.Checkbox({ + displayName: 'Allow External Links', + description: 'Include external domain links in the final results list.', + required: false, + defaultValue: true, + }), + include_images: Property.Checkbox({ + displayName: 'Include Images', + description: 'Include images found on the crawled pages.', + required: false, + defaultValue: false, + }), + extract_depth: Property.StaticDropdown({ + displayName: 'Extract Depth', + description: 'The depth of content extraction per page.', + required: false, + defaultValue: 'basic', + options: { + options: [ + { label: 'Basic', value: 'basic' }, + { label: 'Advanced', value: 'advanced' }, + ], + }, + }), + format: Property.StaticDropdown({ + displayName: 'Format', + description: 'The format of the extracted page content.', + required: false, + defaultValue: 'markdown', + options: { + options: [ + { label: 'Markdown', value: 'markdown' }, + { label: 'Text', value: 'text' }, + ], + }, + }), + }, + async run({ auth, propsValue }) { + return tavilyCommon.request({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: '/crawl', + body: { + url: propsValue.url, + instructions: propsValue.instructions, + max_depth: propsValue.max_depth, + max_breadth: propsValue.max_breadth, + limit: propsValue.limit, + select_paths: propsValue.select_paths, + select_domains: propsValue.select_domains, + exclude_paths: propsValue.exclude_paths, + exclude_domains: propsValue.exclude_domains, + allow_external: propsValue.allow_external, + include_images: propsValue.include_images, + extract_depth: propsValue.extract_depth, + format: propsValue.format, + }, + }); + }, +}); diff --git a/packages/pieces/community/tavily/src/lib/actions/ai/get-research-task.ts b/packages/pieces/community/tavily/src/lib/actions/ai/get-research-task.ts new file mode 100644 index 000000000000..ab23a5ca0c30 --- /dev/null +++ b/packages/pieces/community/tavily/src/lib/actions/ai/get-research-task.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { tavilyAuth } from '../../auth'; +import { tavilyCommon } from '../../common/client'; +import { tavilyGetResearchTaskOutputSchema } from '../../output-schemas'; + +export const getResearchTaskAction = createAction({ + name: 'tavily_get_research_task', + outputSchema: tavilyGetResearchTaskOutputSchema, + classification: 'READ', + displayName: 'Get Research Task', + description: 'Get the status and result of a research task by its request id.', + audience: 'ai', + aiMetadata: { + description: + "Read the status and, once finished, the content and sources of a research task started with `tavily_start_research_task`. Status is one of pending, in_progress, completed, or failed; only a completed task carries content and sources. Use to poll a previously started research task. Read-only and idempotent.", + idempotent: true, + }, + auth: tavilyAuth, + props: { + request_id: Property.ShortText({ + displayName: 'Request ID', + description: 'The request id returned by Start Research Task.', + required: true, + }), + include_usage: Property.Checkbox({ + displayName: 'Include Usage', + description: 'Include credit usage information in the response.', + required: false, + defaultValue: false, + }), + }, + async run({ auth, propsValue }) { + return tavilyCommon.request({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: `/research/${encodeURIComponent(propsValue.request_id)}`, + queryParams: { + ...(propsValue.include_usage !== undefined && { + include_usage: String(propsValue.include_usage), + }), + }, + }); + }, +}); diff --git a/packages/pieces/community/tavily/src/lib/actions/ai/get-usage.ts b/packages/pieces/community/tavily/src/lib/actions/ai/get-usage.ts new file mode 100644 index 000000000000..a1fe9f6a82e9 --- /dev/null +++ b/packages/pieces/community/tavily/src/lib/actions/ai/get-usage.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { tavilyAuth } from '../../auth'; +import { tavilyCommon } from '../../common/client'; +import { tavilyGetUsageOutputSchema } from '../../output-schemas'; + +export const getUsageAction = createAction({ + name: 'tavily_get_usage', + outputSchema: tavilyGetUsageOutputSchema, + classification: 'READ', + displayName: 'Get Usage', + description: 'Get API key and account credit usage for the connected Tavily account.', + audience: 'ai', + aiMetadata: { + description: + 'Read the connected API key and account-level credit usage, broken down by endpoint (search, extract, crawl, map, research). Use to check remaining credits or usage against plan limits before running a costly operation. Read-only and idempotent.', + idempotent: true, + }, + auth: tavilyAuth, + props: { + project_id: Property.ShortText({ + displayName: 'Project ID', + description: 'Optional project id to scope the usage query to a specific project.', + required: false, + }), + }, + async run({ auth, propsValue }) { + return tavilyCommon.request({ + apiKey: auth.secret_text, + method: HttpMethod.GET, + path: '/usage', + headers: { + ...(propsValue.project_id !== undefined && { 'X-Project-ID': propsValue.project_id }), + }, + }); + }, +}); diff --git a/packages/pieces/community/tavily/src/lib/actions/ai/map-website.ts b/packages/pieces/community/tavily/src/lib/actions/ai/map-website.ts new file mode 100644 index 000000000000..8083092989b6 --- /dev/null +++ b/packages/pieces/community/tavily/src/lib/actions/ai/map-website.ts @@ -0,0 +1,95 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { tavilyAuth } from '../../auth'; +import { tavilyCommon } from '../../common/client'; +import { tavilyMapWebsiteOutputSchema } from '../../output-schemas'; + +export const mapWebsiteAction = createAction({ + name: 'tavily_map_website', + outputSchema: tavilyMapWebsiteOutputSchema, + classification: 'SEARCH', + displayName: 'Map Website', + description: 'Crawl a website starting from a root URL and return the URL structure discovered, without page content.', + audience: 'ai', + aiMetadata: { + description: + "Explore a website starting from a root URL, following links up to a depth and breadth limit, and return the list of URLs discovered — no page content. Use to discover a site's structure or find candidate URLs to pass to `tavily_extract` or `tavily_crawl_website`. Not idempotent: the set of URLs discovered can change between runs.", + idempotent: false, + }, + auth: tavilyAuth, + props: { + url: Property.ShortText({ + displayName: 'Root URL', + description: 'The root URL to begin the mapping from.', + required: true, + }), + instructions: Property.LongText({ + displayName: 'Instructions', + description: 'Natural language instructions guiding the crawler.', + required: false, + }), + max_depth: Property.Number({ + displayName: 'Max Depth', + description: 'How far from the root URL the crawler can explore. Range: 1-5.', + required: false, + defaultValue: 1, + }), + max_breadth: Property.Number({ + displayName: 'Max Breadth', + description: 'Maximum number of links to follow per level of the tree. Range: 1-500.', + required: false, + defaultValue: 20, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Total number of links the crawler will process before stopping.', + required: false, + defaultValue: 50, + }), + select_paths: Property.Array({ + displayName: 'Select Paths', + description: 'Regex patterns selecting only URLs with these path patterns.', + required: false, + }), + select_domains: Property.Array({ + displayName: 'Select Domains', + description: 'Regex patterns selecting only specific domains or subdomains.', + required: false, + }), + exclude_paths: Property.Array({ + displayName: 'Exclude Paths', + description: 'Regex patterns excluding URLs with these path patterns.', + required: false, + }), + exclude_domains: Property.Array({ + displayName: 'Exclude Domains', + description: 'Regex patterns excluding specific domains or subdomains.', + required: false, + }), + allow_external: Property.Checkbox({ + displayName: 'Allow External Links', + description: 'Include external domain links in the final results list.', + required: false, + defaultValue: true, + }), + }, + async run({ auth, propsValue }) { + return tavilyCommon.request({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: '/map', + body: { + url: propsValue.url, + instructions: propsValue.instructions, + max_depth: propsValue.max_depth, + max_breadth: propsValue.max_breadth, + limit: propsValue.limit, + select_paths: propsValue.select_paths, + select_domains: propsValue.select_domains, + exclude_paths: propsValue.exclude_paths, + exclude_domains: propsValue.exclude_domains, + allow_external: propsValue.allow_external, + }, + }); + }, +}); diff --git a/packages/pieces/community/tavily/src/lib/actions/ai/start-research-task.ts b/packages/pieces/community/tavily/src/lib/actions/ai/start-research-task.ts new file mode 100644 index 000000000000..ba4c9b5c22dd --- /dev/null +++ b/packages/pieces/community/tavily/src/lib/actions/ai/start-research-task.ts @@ -0,0 +1,93 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { tavilyAuth } from '../../auth'; +import { tavilyCommon } from '../../common/client'; +import { tavilyStartResearchTaskOutputSchema } from '../../output-schemas'; + +export const startResearchTaskAction = createAction({ + name: 'tavily_start_research_task', + outputSchema: tavilyStartResearchTaskOutputSchema, + classification: 'WRITE', + displayName: 'Start Research Task', + description: 'Start an asynchronous, in-depth research task on a topic or question.', + audience: 'ai', + aiMetadata: { + description: + 'Queue a background research task that investigates a topic or question in depth and returns a request id. The task runs asynchronously; poll its result with `tavily_get_research_task` using the returned request id. Use for comprehensive, multi-source research beyond what a single search covers. Not idempotent: every call starts a new task.', + idempotent: false, + }, + auth: tavilyAuth, + props: { + input: Property.LongText({ + displayName: 'Research Question', + description: 'The research task or question to investigate.', + required: true, + }), + model: Property.StaticDropdown({ + displayName: 'Model', + description: '"mini" for targeted research, "pro" for comprehensive multi-angle analysis.', + required: false, + defaultValue: 'auto', + options: { + options: [ + { label: 'Auto', value: 'auto' }, + { label: 'Mini', value: 'mini' }, + { label: 'Pro', value: 'pro' }, + ], + }, + }), + citation_format: Property.StaticDropdown({ + displayName: 'Citation Format', + description: 'Citation style used in the report.', + required: false, + defaultValue: 'numbered', + options: { + options: [ + { label: 'Numbered', value: 'numbered' }, + { label: 'MLA', value: 'mla' }, + { label: 'APA', value: 'apa' }, + { label: 'Chicago', value: 'chicago' }, + ], + }, + }), + output_length: Property.StaticDropdown({ + displayName: 'Output Length', + description: 'Target size of the response.', + required: false, + defaultValue: 'standard', + options: { + options: [ + { label: 'Short', value: 'short' }, + { label: 'Standard', value: 'standard' }, + { label: 'Long', value: 'long' }, + ], + }, + }), + include_domains: Property.Array({ + displayName: 'Include Domains', + description: 'Soft preference domains; research prioritizes these. Max 20.', + required: false, + }), + exclude_domains: Property.Array({ + displayName: 'Exclude Domains', + description: 'Hard blocklist; no URLs from these domains appear. Max 20.', + required: false, + }), + }, + async run({ auth, propsValue }) { + return tavilyCommon.request({ + apiKey: auth.secret_text, + method: HttpMethod.POST, + path: '/research', + body: { + input: propsValue.input, + model: propsValue.model, + citation_format: propsValue.citation_format, + output_length: propsValue.output_length, + include_domains: propsValue.include_domains, + exclude_domains: propsValue.exclude_domains, + stream: false, + }, + }); + }, +}); diff --git a/packages/pieces/community/tavily/src/lib/actions/extract.ts b/packages/pieces/community/tavily/src/lib/actions/extract.ts index bf820f9f5edd..5b31c1d5fbff 100644 --- a/packages/pieces/community/tavily/src/lib/actions/extract.ts +++ b/packages/pieces/community/tavily/src/lib/actions/extract.ts @@ -1,9 +1,11 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { AuthenticationType, httpClient, HttpMethod } from '@activepieces/pieces-common'; import { tavilyAuth } from '../auth'; +import { extractOutputSchema } from '../output-schemas'; export const extractAction = createAction({ name: 'extract', + outputSchema: extractOutputSchema, classification: 'READ', displayName: 'Extract Content', description: 'Retrieve raw web content from specified URLs.', diff --git a/packages/pieces/community/tavily/src/lib/actions/search.ts b/packages/pieces/community/tavily/src/lib/actions/search.ts index c02cfe5b33ec..e1124cff329a 100644 --- a/packages/pieces/community/tavily/src/lib/actions/search.ts +++ b/packages/pieces/community/tavily/src/lib/actions/search.ts @@ -1,9 +1,11 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { AuthenticationType, httpClient, HttpMethod } from '@activepieces/pieces-common'; import { tavilyAuth } from '../auth'; +import { searchOutputSchema } from '../output-schemas'; export const searchAction = createAction({ name: 'search', + outputSchema: searchOutputSchema, classification: 'SEARCH', displayName: 'Search', description: 'Search for data based on a query.', diff --git a/packages/pieces/community/tavily/src/lib/common/client.ts b/packages/pieces/community/tavily/src/lib/common/client.ts new file mode 100644 index 000000000000..890f1bbebc72 --- /dev/null +++ b/packages/pieces/community/tavily/src/lib/common/client.ts @@ -0,0 +1,52 @@ +import { httpClient, HttpMethod, AuthenticationType, HttpError } from '@activepieces/pieces-common'; + +const BASE_URL = 'https://api.tavily.com'; + +async function request({ + apiKey, + method, + path, + body, + queryParams, + headers, +}: { + apiKey: string; + method: HttpMethod; + path: string; + body?: Record; + queryParams?: Record; + headers?: Record; +}): Promise { + try { + const response = await httpClient.sendRequest({ + method, + url: `${BASE_URL}${path}`, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: apiKey, + }, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + queryParams, + body, + }); + return response.body; + } catch (e) { + if (e instanceof HttpError) { + const status = e.response.status; + const message = JSON.stringify(e.response.body); + if (status === 401) { + throw new Error(`Invalid Tavily API key: ${message}`); + } + if (status === 429) { + throw new Error(`Tavily rate limit exceeded: ${message}`); + } + throw new Error(`Tavily API error (${status}): ${message}`); + } + throw e; + } +} + +export const tavilyCommon = { request }; diff --git a/packages/pieces/community/tavily/src/lib/output-schemas.ts b/packages/pieces/community/tavily/src/lib/output-schemas.ts new file mode 100644 index 000000000000..07e619989049 --- /dev/null +++ b/packages/pieces/community/tavily/src/lib/output-schemas.ts @@ -0,0 +1,147 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const tavilyCrawlWebsiteOutputSchema: OutputSchema = { + fields: [ + { key: 'base_url', label: 'Base URL', format: 'url' }, + { + key: 'results', + label: 'Results', + listItems: [ + { key: 'url', label: 'URL', format: 'url' }, + { key: 'raw_content', label: 'Raw Content' }, + ], + }, + { key: 'response_time', label: 'Response Time', format: 'number' }, + { key: 'request_id', label: 'Request ID' }, + ], +}; + +export const extractOutputSchema: OutputSchema = { + fields: [ + { + key: 'results', + label: 'Results', + labelKey: 'title', + listItems: [ + { key: 'url', label: 'URL', format: 'url' }, + { key: 'title', label: 'Title' }, + { key: 'raw_content', label: 'Raw Content' }, + { key: 'images', label: 'Images' }, + ], + }, + { + key: 'failed_results', + label: 'Failed Results', + labelKey: 'url', + listItems: [ + { key: 'url', label: 'URL', format: 'url' }, + { key: 'error', label: 'Error' }, + ], + }, + { key: 'response_time', label: 'Response Time', format: 'number' }, + { key: 'request_id', label: 'Request ID' }, + ], +}; + +export const tavilyGetResearchTaskOutputSchema: OutputSchema = { + fields: [ + { key: 'content', label: 'Content' }, + { + key: 'sources', + label: 'Sources', + labelKey: 'title', + listItems: [ + { key: 'url', label: 'URL', format: 'url' }, + { key: 'title', label: 'Title' }, + { key: 'favicon', label: 'Favicon', format: 'url' }, + ], + }, + { key: 'status', label: 'Status' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { + key: 'usage', + label: 'Usage', + children: [{ key: 'credits', label: 'Credits', format: 'number' }], + }, + { key: 'response_time', label: 'Response Time', format: 'number' }, + { key: 'request_id', label: 'Request ID' }, + ], +}; + +export const tavilyMapWebsiteOutputSchema: OutputSchema = { + fields: [ + { key: 'base_url', label: 'Base URL', format: 'url' }, + { key: 'results', label: 'Results' }, + { key: 'response_time', label: 'Response Time', format: 'number' }, + { key: 'request_id', label: 'Request ID' }, + ], +}; + +export const searchOutputSchema: OutputSchema = { + fields: [ + { key: 'query', label: 'Query' }, + { key: 'follow_up_questions', label: 'Follow Up Questions' }, + { key: 'answer', label: 'Answer' }, + { key: 'images', label: 'Images' }, + { + key: 'results', + label: 'Results', + labelKey: 'title', + listItems: [ + { key: 'url', label: 'URL', format: 'url' }, + { key: 'title', label: 'Title' }, + { key: 'content', label: 'Content' }, + { key: 'score', label: 'Score', format: 'number' }, + { key: 'raw_content', label: 'Raw Content' }, + { key: 'id', label: 'ID' }, + ], + }, + { key: 'response_time', label: 'Response Time', format: 'number' }, + { key: 'request_id', label: 'Request ID' }, + ], +}; + +export const tavilyStartResearchTaskOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Status' }, + { key: 'input', label: 'Input' }, + { key: 'model', label: 'Model' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'response_time', label: 'Response Time', format: 'number' }, + { key: 'request_id', label: 'Request ID' }, + ], +}; + +export const tavilyGetUsageOutputSchema: OutputSchema = { + fields: [ + { + key: 'key', + label: 'Key', + children: [ + { key: 'usage', label: 'Usage', format: 'number' }, + { key: 'limit', label: 'Limit', format: 'number' }, + { key: 'search_usage', label: 'Search Usage', format: 'number' }, + { key: 'crawl_usage', label: 'Crawl Usage', format: 'number' }, + { key: 'extract_usage', label: 'Extract Usage', format: 'number' }, + { key: 'map_usage', label: 'Map Usage', format: 'number' }, + { key: 'research_usage', label: 'Research Usage', format: 'number' }, + ], + }, + { + key: 'account', + label: 'Account', + children: [ + { key: 'current_plan', label: 'Current Plan' }, + { key: 'plan_usage', label: 'Plan Usage', format: 'number' }, + { key: 'plan_limit', label: 'Plan Limit', format: 'number' }, + { key: 'search_usage', label: 'Search Usage', format: 'number' }, + { key: 'crawl_usage', label: 'Crawl Usage', format: 'number' }, + { key: 'extract_usage', label: 'Extract Usage', format: 'number' }, + { key: 'map_usage', label: 'Map Usage', format: 'number' }, + { key: 'research_usage', label: 'Research Usage', format: 'number' }, + { key: 'paygo_usage', label: 'Paygo Usage', format: 'number' }, + { key: 'paygo_limit', label: 'Paygo Limit', format: 'number' }, + ], + }, + ], +}; From 645c177faaaf6052c730488ac1779ad2be823806 Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:07:39 +0530 Subject: [PATCH 6/8] fix(straico): deprecate piece, service is shut down (#15875) --- packages/pieces/community/straico/package.json | 2 +- packages/pieces/community/straico/src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/pieces/community/straico/package.json b/packages/pieces/community/straico/package.json index 20c7383f8a18..51765e7d821f 100644 --- a/packages/pieces/community/straico/package.json +++ b/packages/pieces/community/straico/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-straico", - "version": "0.3.0", + "version": "0.3.1", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/straico/src/index.ts b/packages/pieces/community/straico/src/index.ts index fbeca571a213..b63cc0f42d2f 100644 --- a/packages/pieces/community/straico/src/index.ts +++ b/packages/pieces/community/straico/src/index.ts @@ -37,6 +37,7 @@ export const straico = createPiece({ auth: straicoAuth, minimumSupportedRelease: '0.30.0', logoUrl: 'https://cdn.activepieces.com/pieces/straico.png', + deprecated: true, categories: [PieceCategory.ARTIFICIAL_INTELLIGENCE], description: 'All-in-one generative AI platform', authors: ['dennisrongo'], From 5c49ac8e1d73eb3d8e975b753b125cdffab6febf Mon Sep 17 00:00:00 2001 From: Louai Boumediene <92324961+Louai-Zokerburg@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:31:21 +0100 Subject: [PATCH 7/8] feat(ai): chat and agents resolve model tiers from the published file (#15825) --- brain/knowledge/ai-intelligence/ai-agents.md | 2 +- .../knowledge/ai-intelligence/ai-providers.md | 19 ++- .../engineering/ci-pr-review-hygiene.md | 1 + brain/knowledge/memory.md | 1 + .../reference/environment-variables.mdx | 2 +- .../src/lib/workers/worker-contract.ts | 1 + .../api/src/app/ee/agent/agent-helpers.ts | 21 +-- .../api/src/app/ee/agent/agent-memory-ai.ts | 2 +- .../app/ee/agent/agent-model-resolution.ts | 67 ++++++--- .../api/src/app/ee/agent/agent-service.ts | 2 +- .../src/app/ee/agent/chat-analytics-sync.ts | 2 +- .../api/src/app/ee/agent/chat-tool-billing.ts | 5 +- .../chat-personalization-service.ts | 4 +- .../src/app/ee/agent/rpc/agent-config-rpc.ts | 13 +- .../api/src/app/ee/agent/rpc/rpc-shared.ts | 14 -- .../app/ee/agent/rpc/tool-execution-rpc.ts | 4 +- .../ee/agent/agent-action-audit.test.ts | 2 + .../integration/ee/agent/agent-turn.test.ts | 2 +- .../agent/agent-fast-model-provider.test.ts | 35 +++-- .../ee/agent/agent-model-resolution.test.ts | 141 ++++++++++++++---- .../app/ee/agent/agent-rpc-handlers.test.ts | 17 ++- .../unit/app/ee/agent/model-tier-fixture.ts | 36 +++++ packages/server/api/vitest.setup.ts | 1 + .../jobs/ee/agent/execute-agent-run.ts | 6 +- 24 files changed, 282 insertions(+), 118 deletions(-) create mode 100644 packages/server/api/test/unit/app/ee/agent/model-tier-fixture.ts diff --git a/brain/knowledge/ai-intelligence/ai-agents.md b/brain/knowledge/ai-intelligence/ai-agents.md index b791144568ca..42d478fd9b21 100644 --- a/brain/knowledge/ai-intelligence/ai-agents.md +++ b/brain/knowledge/ai-intelligence/ai-agents.md @@ -19,7 +19,7 @@ A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs | `AGENT_BUILDER` | `conversation.modelName` | tier id | | `FLOW_STEP` | `agent.published.modelName`, or the step's own `input.aiProviderModel.model` | concrete id | -Three things follow. `run_agent` has two modes: pointed at a saved agent (`agentId` set) the step sends no model and the agent row decides; inline, it sends `input.aiProviderModel.model`. An agent row carries two configs: agent-page chat reads `agent.draft`, a `run_agent` step reads `agent.published` (via `resolvePublishedAgent`), so the same agent can already run different models through different doors. The fast model crosses every door: `firstStepUsesFastModel` (`run-agent-turn.ts`) runs the first step of almost every turn on the fast tier, flow steps included, whatever the user picked. `execute-agent-run.ts` persists `config.tier.id` onto `conversation.modelName` when the job carried no `modelName`, so conversations already hold tier ids today (inline `run_agent`, eval runs, every `AGENT_BUILDER`), read back by `chat-tool-billing.ts`, `chat-analytics-sync.ts` and `pinnedModelOf`. +Three things follow. `run_agent` has two modes: pointed at a saved agent (`agentId` set) the step sends no model and the agent row decides; inline, it sends `input.aiProviderModel.model`. An agent row carries two configs: agent-page chat reads `agent.draft`, a `run_agent` step reads `agent.published` (via `resolvePublishedAgent`), so the same agent can already run different models through different doors. The fast model crosses every door: `firstStepUsesFastModel` (`run-agent-turn.ts`) runs the first step of almost every turn on the fast tier, flow steps included, whatever the user picked. `execute-agent-run.ts` persists `config.tier.id` onto `conversation.modelName` when the job carried no `modelName`, so conversations already hold tier ids today (inline `run_agent`, eval runs, every `AGENT_BUILDER`), read back by `chat-tool-billing.ts` and `chat-analytics-sync.ts`. **A persisted tier id is not a pin.** `pinnedModelOf` (`rpc/rpc-shared.ts`) ignores `conversation.modelName` for a conversation with an agent: that value is the resolved default, and once the console removes that tier it would reach a catalogue-less own-key provider as a literal model id. A saved agent's run model is the agent's own half (draft for AGENT, published for FLOW_STEP); only an agentless inline step reads `conversation.modelName`, which is the step's configured model. ### Tool types (AgentTool discriminated union) diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 6c9620816dea..9718d93288f3 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -58,9 +58,12 @@ the release's bundled tiers serve instead. `modelTierCatalog.current(surface)` (`packages/server/utils/src/model-tier-catalog.ts`) is the single accessor: synchronous, never throws, serves the cached file or `ACTIVEPIECES_CHAT_TIERS`, and refreshes in the background every 15 minutes with a 5-minute back-off after a failure. `warmUp()` runs at API boot from -`aiProviderService.setup()`, bounded to 2 s so a dead CDN cannot block boot. Today only `warmUp()` calls it: the -resolution helpers in `ee/agent/agent-model-resolution.ts` (`findTier`, `resolveTier`, `FAST_TIER_ID`) still read -the bundled list and are the next thing to move. `AP_MODEL_TIERS_URL` overrides +`aiProviderService.setup()`, bounded to 2 s so a dead CDN cannot block boot. The resolution family in +`ee/agent/agent-model-resolution.ts` (`findTier`, `resolveTier`, `resolveNamedModelId`, `resolveModelIdForProvider`, +`defaultModelIdForProvider`, `resolveModelIdForAnalytics`) and the fast-model helpers in `agent-helpers.ts` read it, +every one with a required `surface` argument. `surfaceOf({ source })` in that file is the only place the run +source maps to a list (FLOW_STEP and AGENT to flow, CHAT and AGENT_BUILDER to chat), and `agent-config-rpc.ts` +derives `namesItsOwnModel` from it. `AP_MODEL_TIERS_URL` overrides the URL. The file is published from the console in `ap-analytics`; see decision 000041. ### Gotchas - **A catalog change takes up to ~2 days to reach a dropdown, through three caches in series.** CDN edge (`--cache-control max-age=3600`, 1h) → the server's in-memory catalog (`CATALOG_TTL_MS`, 24h) → `modelsCache` (flushed by the nightly `cron.schedule('0 0 * * *')`). So "I republished but the UI still shows the old price" is expected, not a bug; restarting the API short-circuits the two in-process layers. The edge TTL is deliberately 1h and not the 604800 that `publish-embed-sdk.yml` uses — that path is version-stamped, ours is a stable key rewritten in place, so a week-long edge cache would pin stale prices for a week. @@ -70,17 +73,19 @@ the URL. The file is published from the console in `ap-analytics`; see decision - **A new provider needs an entry in the generator's `MODELS_DEV_PROVIDER` map or it silently ships with no metadata.** models.dev provider ids do not match ours: `bedrock` → `amazon-bedrock`, `qwen` → `alibaba`, `moonshot` → `moonshotai`, `activepieces` → aliased onto `openrouter` at lookup time. The six OpenAI-compatible vendors were merged before the map was updated and produced exactly this — the run prints `no upstream source: …`, which is the thing to read after adding a provider. `cloudflare-gateway` and `custom` legitimately have no source. - **The catalog object must be published before the code that reads it ships.** There is no bundled copy, so until a `workflow_dispatch` run puts it on the CDN, every install — including local dev — shows no metadata at all. - **The pricing console shows bundled constants when no file is published, and says nothing.** `overview()` in `ai-pricing-service.ts` has three states, and two of them render the same numbers: **absent** (`NoSuchKey`) silently seeds the editor from `DEFAULT_PRICING_DRAFT`, the console's own checked-in copy of the tiers and weights, while **unreadable** (any other S3 error) shows those same constants *plus* a banner and blocks publishing. Only **present** is live data. So a screen full of plausible tiers is not evidence that anything was ever published — `curl -I https://cdn.activepieces.com/ai/pricing.json` is. The absent state is deliberate, it is how the first publish bootstraps, but it means "I can see the tiers" and "the prices are live" are unrelated facts. Worse, **absent and misconfigured are the same state**: `cdnClient.getJson` catches `NoSuchKey` and returns `undefined`, so a wrong bucket or an endpoint with the bucket in its path reads as "nothing published yet" and hides behind the defaults. The tell is the History tab — `list` does *not* catch `NoSuchKey`, so the same misconfiguration that reads as empty on the pricing page returns a 500 there. A 500 on history with a normal-looking pricing page means the S3 addressing is wrong, not that the history is empty. -- **Deleting the live tier file is safe for customers and confusing for everyone else.** `ai/pricing.json` was deleted on 2026-09-24 to drop a leftover test tier, and Activepieces kept serving the right tiers throughout: the 404 makes `fetchTiers` throw, the failure is logged and backed off, and `current()` keeps serving the last good copy the process fetched; only a process that never fetched one, a fresh boot during the outage, runs the `ACTIVEPIECES_CHAT_TIERS` shipped with the release. Two things do change. Each server logs 'Failed to refresh the AI model tiers file' (or 'Failed to load…' on a process that never got a copy) on every failed attempt, and attempts happen at boot and whenever `current()` is called with a stale or missing copy, at most once per five minutes (`FAILURE_BACKOFF_MS`), so a permanent 404 is retried the same way as a blip. Today only `warmUp()` calls it, so that is one line per boot. And the console reads the missing key as **absent**, so the next publish starts again at `version: 1` while the `ai/pricing/history/` objects from the old numbering are still there — the version number is `live.version + 1`, not a counter, so it is only meaningful between two publishes that never lost the live key. To remove one bad tier, edit and publish; do not delete the object. +- **Deleting the live tier file is safe for customers and confusing for everyone else.** `ai/pricing.json` was deleted on 2026-09-24 to drop a leftover test tier, and Activepieces kept serving the right tiers throughout: the 404 makes `fetchTiers` throw, the failure is logged and backed off, and `current()` keeps serving the last good copy the process fetched; only a process that never fetched one, a fresh boot during the outage, runs the `ACTIVEPIECES_CHAT_TIERS` shipped with the release. Two things do change. Each server logs 'Failed to refresh the AI model tiers file' (or 'Failed to load…' on a process that never got a copy) on every failed attempt, and attempts happen at boot and whenever `current()` is called with a stale or missing copy, at most once per five minutes (`FAILURE_BACKOFF_MS`), so a permanent 404 is retried the same way as a blip. And the console reads the missing key as **absent**, so the next publish starts again at `version: 1` while the `ai/pricing/history/` objects from the old numbering are still there — the version number is `live.version + 1`, not a counter, so it is only meaningful between two publishes that never lost the live key. To remove one bad tier, edit and publish; do not delete the object. - **`CDN_S3_*` and `AP_S3_*` are two different buckets on two different providers, and mixing them puts public objects in customer data.** `cdn.activepieces.com` is a **DigitalOcean Spaces** bucket, `activepieces-cdn` in `fra1`, fronted by Cloudflare — a 200 from it carries `x-rgw-object-type` and `x-do-cdn-uuid`, which is how you tell. That is where `ai/model-catalog.json`, `ai/pricing.json` and `sdk/embed/*.js` live, and the credentials are the `CDN_S3_*` GitHub secrets. `AP_S3_BUCKET` is something else entirely: the **Cloudflare R2** file-storage bucket (`ap-files-prod` in production) holding run logs, step checkpoints, webhook payloads and flow bundles. The names are similar enough that the pricing console was once configured with the R2 file-storage key by mistake, which would have written a `public-read` `ai/pricing.json` into the customer-data bucket while `cdn.activepieces.com` stayed 404. When wiring anything that publishes to the CDN, confirm the endpoint is the Spaces region origin (`https://fra1.digitaloceanspaces.com`, never with the bucket in the path — the SDK appends `Bucket` itself and the doubled path returns `NoSuchKey` on every call). - **The weekly sync workflow can fail and nobody is told.** `sync-model-catalog.yml` has no alert on failure, and the only signal that it broke is a 404 on the object, which every reader swallows. Its run on 2026-08-31 failed with `InvalidAccessKeyId` — the `CDN_S3_ACCESS_KEY_ID` secret in this repo does not match a live key — so `ai/model-catalog.json` has never actually landed. Check `gh run list --workflow=sync-model-catalog.yml` before believing the CDN is the problem. **That same `CDN_S3_*` credential also publishes the AI tier file `ai/pricing.json` from `ap-analytics`** (decision [000039](../decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md)), so one dead key silently breaks model metadata *and* the published tiers at once: the console hides model costs (`model-catalog-service.ts` logs 'model prices will be hidden' and returns an empty list) while Activepieces keeps serving the tiers bundled in the release. Two features, one credential, no alarm on either. - **A fresh Cloud platform cannot connect any AI provider, and the UI says nothing about why.** The admin page at `/platform/ai` sets `allowWrite = platform.plan.aiProvidersEnabled`, and that column defaults to **false** on the `free` plan (migration `1776…AddDefaultToAiProvidersEnabled`), so the connect button is simply absent rather than disabled-with-a-reason. On Cloud the flag is owned by Autumn (`autumn-utils.ts` lists it among the synced features), so there is nothing to configure locally and no `DEV_ENTERPRISE_PLAN` escape hatch on main. For local Cloud testing, flip it directly: `UPDATE platform_plan SET "aiProvidersEnabled" = true`. Then connect a **BYO key**, not the managed `ACTIVEPIECES` provider — per *Provider visibility* above, the managed row is hidden whenever `OPENROUTER_PROVISION_KEY` is unset, which is the normal local state, and a hidden provider makes `getChatProvider()` return null. Anything gated on a chat provider (chat itself, personalization research) stays silently disabled until a BYO row exists. - **Since the AI steps moved to the worker, the server sees every step's model id, and `ai-execute-controller` is the one place to act on it.** The piece POSTs `/v1/ai/execute` with `provider` and `modelId`, the controller builds the `EXECUTE_AI` job from that body, and the worker runs whatever model id the job carries. So anything that needs to rewrite or validate the model a flow step is about to use belongs there, not in the piece — one server-side edit covers all five AI steps, and an old pinned piece bundle keeps working because it only forwards the string it stored. This is what makes storing a tier id in a step cheap: the bundle never has to understand it. Note the agent step does **not** come through here — it resolves separately over `getAgentConfig`, in `agent-config-rpc`. Two conditions sit under that. **An old bundle only forwards the string from `0.11.0` up** — `AI_PIECE_COST_BILLING_VERSION`, the first version whose `ai-step.ts` POSTs `v1/ai/execute`. A step pinned below it runs the pre-worker code that calls the provider from the sandbox, so a tier id there would reach OpenRouter as the literal string. That case is already closed: `migrate-v26-ai-piece-cost-billing` bumps every saved `piece-ai` step below `0.11.0` up to it, which is why a later migration can rewrite what a step stores without touching `pieceVersion` for the runtime. **And resolve on the API, never in the worker**: `ExecuteAiRequest.modelId` is a bare `z.string()` so a tier id passes validation, `getEffectiveProviderAndModel` and `aiUtils.buildWebSearchToolsOrThrow` both read `data.modelId` as a real model, and the `resolveAiProvider` RPC does not carry the model at all, so using it as the seam would mean widening the worker contract. - **Managed billing can only price an OpenRouter call, and a call it cannot price is simply not billed.** `openRouterCostOf` (`server/utils/activepieces-ai-cost.ts`) reads `providerMetadata['openrouter'].usage.cost`, with one fallback that GETs `openrouter.ai/api/v1/generation?id=` and takes `total_cost`. If neither answers, `createBillingInfo` returns `undefined` and the turn is never reported — it runs, the customer keeps the output, and nothing is charged. Nothing logs "unbilled". This is what actually welds the managed provider to OpenRouter, more than the client does: `create-language-model.ts` shares one branch for `OPENROUTER` and `ACTIVEPIECES`, every tier `modelId` is an OpenRouter id, and `openrouter-api.ts` mints the per-platform key from `OPENROUTER_PROVISION_KEY`. So putting managed credits on any other gateway needs a second cost extractor and a second generation lookup **first**, or every call through it bills zero. For reference, no Vercel AI Gateway code exists here at all — the only gateway in the repo is Cloudflare's (`CLOUDFLARE_GATEWAY`, the `ai-gateway-provider` package), and `@ai-sdk/gateway` appears only as a transitive dep of `ai` that nothing imports. A BYOK user can already reach Vercel's gateway today through `AIProviderName.CUSTOM`, which takes a base URL plus hand-typed model ids. -- **A tier id that no longer exists resolves two different ways, and only the run source decides which.** `getAgentConfig` branches on `namesItsOwnModel` (true for `AgentRunSource.FLOW_STEP` and `AGENT`): those go through `resolveNamedModelId`, which **throws** `The model "X" is not available on Activepieces AI credits` for an id it cannot find, while every other source goes through `resolveModelIdForProvider` → `resolveTier`, which **silently returns the default tier**. So deleting a published tier moves existing chats onto the default with no error and no notice, and that is genuinely safe only while flow steps and agents still store concrete model ids. The moment a step stores `premium` it takes the throwing branch and every run of that flow fails, because the candidate list is model ids and never contains a tier id. Whoever lands the tier-id switch has to decide what a removed tier means before the console keeps telling admins that removing one is safe. +- **A tier id that no longer exists resolves to the default tier of its surface, on every door, with a warning.** `resolveNamedModelId` (the path FLOW_STEP and AGENT take) tells a tier id from a model id by the slash rule: a bare slug is a tier id and goes through `resolveTier`, which returns the surface default for an id the file no longer carries and logs `Tier is no longer published; running the surface default` with the tier id and surface; a slashed id is a managed model id and is still refused with `The model "X" is not available on Activepieces AI credits` unless the release ships it or a published tier on that surface carries it. Every other source goes through `resolveModelIdForProvider` → `resolveTier`, the same default. So removing a tier from the file never fails a flow or an agent; it moves them onto the default until they are re-picked. The console may keep saying that removing a tier is safe. Decided 2026-09-26 with PR 2 of the configurable tiers work. - **The console's Publish writes the live key directly; there is no draft, no staging and no filter.** `ai-pricing-service.ts` in `ap-analytics` writes `LIVE_PRICING_KEY` on Publish, and its "add tier" row seeds `id: tier-N`, `label: New tier`, so a half-finished experiment is one click from every server's cached tier list, with only the CDN `max-age=300` plus the 15-minute in-process TTL in between. Test against a local file with `AP_MODEL_TIERS_URL` (add the host to `AP_SSRF_ALLOW_LIST` for loopback), never by publishing. - **The AI piece's managed dropdown only ever offers tier models, so every stored managed model id came from a tier pick.** `props.ts` (`packages/pieces/community/ai/src/lib/common/props.ts`) builds its options from `ACTIVEPIECES_CHAT_TIERS`, the constant compiled into the release, in two places: `managedModelLabel` supplies the label, and the same lookup is the `.filter` that decides which models are offered at all. There is no way to pin a model deliberately through it. Two consequences. A re-pointed tier is invisible to the AI piece in both directions: a saved step keeps its concrete model id, and a new step cannot pick the new model because it matches no bundled tier. And any migration from stored model ids to tier ids has a trivially complete mapping, since each stored id is one of the three bundled tier model ids of the release it was saved on. Until the dropdown emits tier ids, the AI piece only ever follows the release. - **`AIProviderModel` is declared twice, and a field added to one half is stripped on the wire.** There is one in `core/piece-types` (what pieces import, since they cannot import `@activepieces/shared`) and one in `core/shared` (what the server and the response schema use). They are not re-exports of each other — today the shared copy carries `metadata` and the piece-types copy does not. Fastify serialises the response against the *shared* schema and drops every key it does not declare, so adding a field to the piece-types copy alone type-checks in the piece, type-checks in the server, and arrives as `undefined`. A published tier label was nearly shipped that way. Any new field on a provider model needs both files. - **No image model belongs to a chat tier, so a tier-based filter empties the image picker.** The managed provider is OpenRouter underneath and `openrouter-provider.listModels` tags a model `IMAGE` when its `architecture.output_modalities` says so — `ACTIVEPIECES` is deliberately absent from `NO_IMAGE_GENERATION_PROVIDERS`, and `google/gemini-2.5-flash-image` and friends are real managed models. But `ACTIVEPIECES_CHAT_TIERS` is three *chat* tiers, so no image model can ever match one. Any filter of the form "a managed model must belong to a tier" therefore drops every image model silently, leaving `aiProps({ modelType: 'image' })` with an empty dropdown and no error. Gate that filter on `modelType === 'text'`. +- **The api test suite runs on the bundled tiers, never the live file.** `packages/server/api/vitest.setup.ts` points `AP_MODEL_TIERS_URL` at `model-tiers.invalid`, so boot warm-up fails fast and every test resolves against `ACTIVEPIECES_CHAT_TIERS`. CI runners have egress, and the first time a resolver read the file in CI the live `defaultTierId` was `fast`, which turned a test asserting the release default (Expert) red with no code change behind it. A test that wants the published shape mocks `modelTierCatalog` (`test/unit/app/ee/agent/model-tier-fixture.ts`); an integration test that boots the app cannot be handed a file. +- **A published tier without `nativeModelId` keeps every BYOK key on the release's model for that tier id.** `nativeModelIdFor` (`ee/agent/agent-model-resolution.ts`) takes the tier's own `nativeModelId`, else the `nativeModelId` of the bundled `ACTIVEPIECES_CHAT_TIERS` entry with the same id, else null, which sends a curated provider to its first curated model and a catalogue-less provider to the first text model its key lists. The fallback is keyed by tier id only, so two lists sharing an id resolve to the same native model on every own-key provider, whatever their managed models are. The BYOK tier map (PR 6 of the configurable tiers work) is the real fix. - **`premium` pairs `modelId: anthropic/claude-opus-4.8` with `nativeModelId: claude-opus-4-7` on purpose.** The managed tier runs Opus 4.8 through OpenRouter; a customer's own Anthropic key runs Opus 4.7. The reason is cost on the customer's key: Heavy on a BYOK key must not silently move the customer onto the most expensive model without them choosing it. The console's `defaults.ts` carries the same pair. Do not "fix" either copy. - **Every provider auth schema is written twice, and only one copy is the one the server reads.** `packages/core/utils/src/lib/ai-provider-credentials.ts` (plain zod) backs the API and the web app; `packages/core/piece-types/src/lib/ai-providers.ts` (`zod/mini`, so `.check(z.minLength(1))` and `z.optional(x)` instead of `.min(1)` / `.optional()`) is what `@activepieces/pieces-framework` re-exports to pieces and the engine. Adding a credential field to only one leaves the other silently stripping it — zod objects drop unknown keys, so the upsert still returns 200 and the failure surfaces later as a provider-side auth error. Change both. @@ -113,7 +118,7 @@ the URL. The file is published from the console in `ap-analytics`; see decision - **The OpenAI-compatible provider can speak the Responses API, and `@ai-sdk/openai-compatible` is not how you get there.** That SDK exposes only `chatModel` (`/chat/completions`) and `completionModel` — no responses model in any version. The route is `@ai-sdk/openai`, which accepts a custom `baseURL` + `headers` and exposes `.responses()`; the Cloudflare gateway's openai branch already did this before the `apiStyle: 'chat' | 'responses'` config field existed. Header precedence is the subtlety: the SDK builds `{ Authorization: Bearer , ...options.headers }`, so a caller header wins on `Authorization` (the Bedrock case, where the key *is* the bearer token) but a custom `apiKeyHeader` such as `x-api-key` leaves the SDK's default `Authorization` riding along. `withUserAgentSuffix` drops `undefined` values, but `OpenAIProviderSettings.headers` is typed `Record`, so the way to strip it is the header-stripping `fetch` the Cloudflare branch uses — no cast needed, and worth reaching for straight away rather than writing the duplicate header up as an accepted limitation. Strip only when `apiKeyHeader` is not itself Authorization, compared **case-insensitively**: HTTP header names are, and an admin can type `authorization`. Confirmed shape, since it is easy to assert on the wrong key: the SDK lowercases everything, so a mis-set provider really does emit `{authorization: 'Bearer ', 'x-api-key': ''}` — the credential twice. - **AWS's OpenAI-compatible surface is two endpoints, and "Bedrock Mantle" is a real AWS product name, not a customer's nickname.** `bedrock-runtime.{region}.amazonaws.com/openai/v1` (AWS-recommended) and `bedrock-mantle.{region}.api.aws/v1` both serve Chat Completions *and* Responses, both authenticate with a Bedrock API key as `Authorization: Bearer`, and both take an unmodified OpenAI SDK. Mantle alone adds server-side tools and web search, `background=true` async inference, and Projects/Workspaces; bedrock-runtime alone has Guardrails, cross-Region inference and intelligent prompt routing. So a ticket claiming "Mantle only supports responses" is half right — it serves chat/completions too, and the real loss is the Responses-only capabilities. - **Vertex is a different door onto the same models, not a different vendor.** The `GOOGLE` provider is the Gemini Developer API (`generativelanguage.googleapis.com`, static API key); `VERTEX` is Vertex AI (`{region}-aiplatform.googleapis.com/v1beta1/projects/{project}/locations/{region}/publishers/google`, service-account OAuth2 with ~1h tokens) — the same Gemini models, but billed to the customer's GCP account and inheriting IAM, VPC-SC, CMEK, data residency and audit logs, plus Model Garden's Claude/Llama/Mistral. That rotating token is why the CUSTOM provider can never reach Vertex: `buildOpenAICompatibleHeaders` injects one static header, which is why the workaround used to be a LiteLLM proxy. `@ai-sdk/google-vertex` does the JWT-to-token exchange itself, so the native provider needs no proxy. Vertex models are entered by hand (`MANUAL_MODEL_PROVIDERS`) on purpose: availability is project-, region- and Model-Garden-specific, and a `publishers/google` listing would miss exactly the third-party models that motivate choosing Vertex. **But "same models" only holds for Gemini.** Model Garden also serves Anthropic, Meta, Mistral and xAI, and those do not share Gemini's API surface — `@ai-sdk/google-vertex` ships separate `/anthropic`, `/maas` and `/xai` entry points for exactly that reason, so one `createVertex(...)` call does not cover Vertex. Because models are typed in by hand, a Claude id reaches the factory as an ordinary string and, unrouted, gets built with the Gemini client and fails at the endpoint. `create-language-model.ts` routes ids containing `claude` to `createVertexAnthropic`; `vertexClientFor` picks the client from the id shape, so all three are routed. Because the shapes are unambiguous the check is a lookup, not a tuned heuristic — and note the publisher prefix has to be tested *before* the `claude` one, or a MaaS path containing "claude" routes to the Anthropic client. Routing is cheap because the id shapes are distinct, not because a heuristic was tuned: `gemini-2.5-pro` is bare, Anthropic carries an `@date` (`claude-3-5-sonnet@20241022`), and MaaS is publisher-prefixed and suffixed (`meta/llama-4-scout-17b-16e-instruct-maas`). The publisher prefix is a stronger discriminator than the `includes('claude')` match currently shipped, and it looks like an argument for listing models rather than taking them by hand — but it is not, see below: the listing is scoped to a single publisher, so Model Garden ids stay hand-typed and the string matching stays with them. **Listing was considered and rejected**, and the reason is worth keeping so it does not get reopened, and **the response shape does not need a GCP account to learn** — every Google API publishes an unauthenticated discovery document: `curl -s 'https://aiplatform.googleapis.com/$discovery/rest?version=v1beta1'` returns ~5MB of JSON whose `schemas` are the authoritative request/response types. `publishers.models.list` returns `{ publisherModels: PublisherModel[], nextPageToken }`, and `PublisherModel` is `{ name: 'publishers/google/models/', versionId, versionState (STABLE|UNSTABLE), launchStage (GA|PUBLIC_PREVIEW|PRIVATE_PREVIEW|EXPERIMENTAL), openSourceCategory, supportedActions, predictSchemata, frameworks, parent }`. **There is no `displayName` on it** — the one in `google-vertexai`'s piece comes from `@google/genai`'s `Model` type, which is a different shape, so copying that code gives every row a blank label with nothing failing. `displayName` exists only on the nested `parent`, which is the base model a tuned model derives from. The decisive fact is in that same doc: `publishers.models.list` takes `parent` matching `^publishers/[^/]+$` — **one publisher per call**. Listing everything therefore means hardcoding the set of publishers to enumerate, which is the same shape of hardcoding it was meant to remove, and Model Garden models would still need hand entry, leaving two mechanisms where there is now one. `VERTEX` stays in `MANUAL_MODEL_PROVIDERS` alongside `CUSTOM` and `CLOUDFLARE_GATEWAY`, which fits: Vertex model availability really is per-project. Reach for the discovery doc before a live probe on any `*.googleapis.com` integration; a curl against the real endpoint needs **billing attached to the project even for a read** (`403 BILLING_DISABLED`), so it is the slower path and it is not free to set up. The `x-goog-user-project` header is not optional when probing with a personal login: without it aiplatform answers `403 SERVICE_DISABLED` naming `consumer: projects/32555940559`, which is Google's shared gcloud CLI project rather than yours, and the message talks about quota projects rather than the missing header. This is a user-credential quirk only — a service account carries its own `project_id`, so the provider itself never hits it. **The multi-vendor story is text only.** Vertex's image models are Google's own Imagen (`imagen-3.0-*`, `imagen-4.0-*`), not Model Garden's third parties, and `createVertex(...)` also exposes `imageModel`, `video`, `speech`, `transcription` and `textEmbeddingModel` — none of which we wire today. Imagen is wired (`createVertex(...).imageModel()`, with a `VERTEX` case in the piece's image switch, so `VERTEX` is no longer in `NO_IMAGE_GENERATION_PROVIDERS`); video, speech, transcription and embeddings are not. Embeddings still have no `DEFAULT_EMBEDDING_MODELS` entry, so that path paths fail with an explanatory message instead of reaching a switch that has no case for it. -- **Chat-tier resolution answers "which model" from three places, and only one of them is the key.** `resolveModelIdForProvider` (`ee/agent/agent-model-resolution.ts`, spread into `agentHelpers`) now prefers the resolved key's own configured text models when its config carries a `models` array — that is the `MANUAL_MODEL_PROVIDERS` case (Vertex, Custom, Cloudflare Gateway), where the admin typed the exact ids the key exposes. **An empty catalog is not a missing one**, and collapsing the two is how the first cut of this went wrong: a helper returning `string[] | undefined` treated "lists zero text models" the same as "has no catalog", so a key configured with only image models fell through to the curated list and resolved to a Gemini id it never offered. An admin-listed catalog is the whole truth about a key, so an empty one now refuses the turn with a message rather than guessing. Otherwise it falls back to the static `ALLOWED_CHAT_MODELS_BY_PROVIDER`, and if the provider is missing from that too it returns the raw tier id with its vendor prefix stripped — which is how a Vertex key once resolved to `claude-sonnet-4-6` and handed it to the Gemini client. That map is `Partial<`, so omitting a chat-capable provider compiles cleanly and fails only at the endpoint. **The key's own `modelScope`/`modelIds` allow-list is applied last, to whichever candidate list was chosen**, and a resolution with nothing left refuses the turn rather than returning a model the key forbids. `GetProviderConfigResponse` carries both fields for that, populated at all three construction sites (`getChatProvider`, `getConfigOrThrow`, `enrichWithKeysIfNeeded`); adding fields there is backward compatible because the AI piece reads the response as a typed shape rather than parsing it. I first recorded this as too big for a provider PR — "a wire contract the engine and the AI piece both consume" — having never measured it. It was about thirty lines across four files. Measure before recording something as out of scope; the estimate outlives the guess. **The resolver is exported and has five call sites across four files, and only two of them are runs.** `agent-rpc-handlers` resolves the chat turn's model *and* its fast model, and `chat-personalization-service` resolves both for research — those four must be given the resolved row's `config`, `modelScope` and `modelIds`, or the key's constraints are bypassed on exactly the paths a user takes. `resolveTierModel` inside `agent-helpers` is the fifth. `agent-service` also calls it but holds only a provider name and sets a stored default on a draft, so it is correctly left thin (`agent-draft-ai` did too, until #15676 deleted it). Adding a parameter here means grepping `resolveModelIdForProvider|resolveFastModelId` across `packages/server`, not editing the call site in front of you — threading only `resolveTierModel` looks complete, passes every test, and still leaves the real chat path unconstrained. One trap when touching this function: a tier id (`fast`, `smart`) is **not** a model id, so the preferred pick must be the resolved native id unless the selection is itself among the candidates — passing `selectedModel` straight through makes `resolveFastModelId` return the first curated model instead of the fast tier's. +- **Chat-tier resolution answers "which model" from three places, and only one of them is the key.** `resolveModelIdForProvider` (`ee/agent/agent-model-resolution.ts`, spread into `agentHelpers`) now prefers the resolved key's own configured text models when its config carries a `models` array — that is the `MANUAL_MODEL_PROVIDERS` case (Vertex, Custom, Cloudflare Gateway), where the admin typed the exact ids the key exposes. **An empty catalog is not a missing one**, and collapsing the two is how the first cut of this went wrong: a helper returning `string[] | undefined` treated "lists zero text models" the same as "has no catalog", so a key configured with only image models fell through to the curated list and resolved to a Gemini id it never offered. An admin-listed catalog is the whole truth about a key, so an empty one now refuses the turn with a message rather than guessing. Otherwise it falls back to the static `ALLOWED_CHAT_MODELS_BY_PROVIDER`, and if the provider is missing from that too it returns the raw tier id with its vendor prefix stripped — which is how a Vertex key once resolved to `claude-sonnet-4-6` and handed it to the Gemini client. That map is `Partial<`, so omitting a chat-capable provider compiles cleanly and fails only at the endpoint. **The key's own `modelScope`/`modelIds` allow-list is applied last, to whichever candidate list was chosen**, and a resolution with nothing left refuses the turn rather than returning a model the key forbids. `GetProviderConfigResponse` carries both fields for that, populated at all three construction sites (`getChatProvider`, `getConfigOrThrow`, `enrichWithKeysIfNeeded`); adding fields there is backward compatible because the AI piece reads the response as a typed shape rather than parsing it. I first recorded this as too big for a provider PR — "a wire contract the engine and the AI piece both consume" — having never measured it. It was about thirty lines across four files. Measure before recording something as out of scope; the estimate outlives the guess. **The resolver is exported and has five call sites across four files, and only two of them are runs.** `agent-rpc-handlers` resolves the chat turn's model *and* its fast model, and `chat-personalization-service` resolves both for research — those four must be given the resolved row's `config`, `modelScope` and `modelIds`, or the key's constraints are bypassed on exactly the paths a user takes. `resolveTierModel` inside `agent-helpers` is the fifth. `agent-service` also calls it but holds only a provider name and sets a stored default on a draft, so it is correctly left thin (`agent-draft-ai` did too, until #15676 deleted it). Adding a parameter here means grepping `resolveModelIdForProvider|resolveFastModelId` across `packages/server`, not editing the call site in front of you — threading only `resolveTierModel` looks complete, passes every test, and still leaves the real chat path unconstrained. The `surface` argument was added as a required key on the whole family for exactly that reason: a missed call site is a compile error, except inside a `vi.mock` factory, which is not type-checked and has to be updated by hand. One trap when touching this function: a tier id (`fast`, `smart`) is **not** a model id, so the preferred pick must be the resolved native id unless the selection is itself among the candidates — passing `selectedModel` straight through makes `resolveFastModelId` return the first curated model instead of the fast tier's. - **A failed credential validation tells the admin nothing, for every provider except Cloudflare Gateway.** `aiProviderService.validateProviderCredentials` gates the upstream message behind `includeHttpErrorInMessage`, which is `provider === CLOUDFLARE_GATEWAY` and nothing else, so everyone else gets a bare `Failed to validate credentials for `. The cause is not lost — it is logged one line earlier (`log.error({ error }, '[aiProviderService#validateProviderCredentials] ...')`) and passed as the `httpErrorResponse` error param — but **web never renders `httpErrorResponse`**, so the only way to diagnose a rejected key is the server log. Grep the log for `validateProviderCredentials` before assuming the provider integration is broken — that text is the whole diagnosis, and it is often not about credentials at all. Confirmed case: a brand-new xAI team with no credits purchased answers `GET /v1/models` with `403 permission-denied — Your newly created team doesn't have any credits or licenses yet`, naming the console page that fixes it, and we render that as "Failed to validate credentials for xAI" — sending the admin off to regenerate a key that was never wrong. Vendors also phrase real key failures inconsistently (xAI uses `400 Incorrect API key provided`, not a 401). The corollary: **a provider that saves without error is not a working provider.** A no-credits 403 and a bad key are indistinguishable in the UI, so only an actual generation proves a key end to end. This is an admin-only surface (`platformAdminOnly`), so there is little reason to keep hiding it. - **A provider's logo is an asset someone has to upload, not something the code ships.** `AiProviderInfo.logoUrl` in `packages/web/src/features/agents/ai-providers.ts` is a plain string rendered into an ``, and every provider points at `https://cdn.activepieces.com/pieces/.png` — nothing is bundled. Adding a provider therefore carries a cross-team dependency with no compile-time or test signal: a slug with no asset behind it renders a broken-image icon in the platform admin list, and only a live request tells you. Check the URL with `curl -o /dev/null -w '%{http_code}'` before assuming it works — a vendor that already ships as a *piece* usually has its logo there already (`deepseek.png`, `grok-xai.png` did), so start the upload request only for the genuinely missing ones. A Vite asset import also satisfies `logoUrl` (see `GoogleIcon` in `platform/security/sso/index.tsx`) and removes the runtime CDN dependency for air-gapped installs, but it diverges from every other provider — treat it as a fallback, not the default. - **The manual-models panel stores a model id and nothing else, so every model an admin adds is typed `TEXT`.** `MANUAL_MODEL_PROVIDERS` (Vertex, Custom, Cloudflare Gateway) persist a `ProviderModelConfig` of `{ modelId, modelName, modelType }`, but since #14825 the UI keeps only `string[]`: `ManualModelList` is a chip list of ids, and `config-detail.tsx` rebuilds the objects on save with `?? { modelId, modelName: modelId, modelType: TEXT }`. The old `universal-pieces/model-form-popover.tsx` had all three fields; replacing it with chips dropped two of them, and no schema or test noticed because the reconstruction is type-correct. The visible damage is not just a mislabelled model: `cloudflareGatewayProvider.validateConnection` pings **only text models** with a `/chat/completions` request, so one image model added through the UI is validated as chat, fails, and takes the whole key down with `These models have issues: ...` — the admin cannot save a key that has an image model on it at all. Anything reading `modelType` downstream (the agent text-model filter, `ap-list-ai-models`, the builder's image pickers) is wrong the same way. Fixed by holding `ProviderModelConfig[]` in the draft rather than `string[]` (no migration; stored rows always had the right shape), so the standing rule is that **a manual-models UI must carry `modelType` all the way to the payload** — a panel that collapses models to ids reintroduces exactly this, and nothing downstream will complain until an admin adds an image model. Keys saved while the bug was live still hold image models typed `TEXT`; the type badge on each chip flips one in place, which is why it is editable rather than display-only. @@ -185,7 +190,7 @@ the URL. The file is published from the console in `ap-analytics`; see decision - **`/v1/ai-tools` is registered only in the CLOUD and ENTERPRISE branches of `app.ts`, but the AI Center page that reads it is not edition-gated** — so a Community admin opening the Capabilities tab fired `useAiToolConfigs`, got Fastify's `Route not found`, and the page showed the global "Failed to load data" dialog of the day. Shipped that way from #13911 until the tab was gated on `ApFlagId.EDITION` in the page. What made this class of bug hard to place is that the surface was then page-independent (raised from `QueryCache.onError` in `query-client.ts`) and React Query's 3 default retries meant it landed several seconds later on whatever page you navigated to next (the report was against `/platform/general`). The tab now renders `DataFetchErrorState` in place instead, so a repeat would at least accuse the right page. When a screenshot's edition is in doubt, read the sidebar: **Billing & subscription** and **Usage** carry a lock only when `edition === COMMUNITY`, every other lock there is plan-driven. Any new EE-only route needs its UI entry point gated the same way, `enabled:` on the query or hiding the surface. - **The AI Center providers page renders its whole state machine off one query, so every branch needs an `isLoading` gate above it.** `ProvidersTab` derives the empty state, the grouped list *and* the `?config=` detail view from `useAiProviderConfigs()`. While that query is pending, `data` is `undefined` — `configs` is `[]`, so the empty "Connect your first provider" screen paints for a frame, and a hard refresh on a `?config=` URL finds no matching row and falls through to the list before flipping to the detail page. The parent `SuspenseWrapper` does not cover it: the route chunk and the suspense-backed projects collection resolve first, then the component mounts with no providers yet. Any new branch added to this page belongs below the `if (isLoading) return ` gate, not beside it. - **A save bar in the AI Center needs a full-height column, not `fixed` and not bare `sticky`.** The page sits inside *two* nested scrollers — `PlatformLayout`'s `flex flex-col flex-1 overflow-auto` and the AI Center's own `flex-1 overflow-auto` column — so `fixed inset-x-0` centres the bar on the viewport, roughly a sidebar-width left of the content column it belongs to. Swapping it to `sticky bottom-4` fixes the centring for free but pins nothing when the content is shorter than the viewport: the bar just lands wherever the content ends, mid-page. The working shape is `min-h-full` + `flex flex-col` on the tab-content wrapper, `flex-1` on the `TabsContent`, `grow` on the panel root, then `sticky bottom-4 mt-auto` on the bar — `mt-auto` handles the short page, `sticky` handles the long one. `min-h-full` resolves only because every ancestor up to `SidebarInset h-full` has a definite height; that chain is what makes the whole thing work, so check it before reusing this elsewhere. -- **A chat *tier* is our own idea, so it has no meaning on a provider we ship no curated model list for — resolve it by asking the key what it offers.** `ACTIVEPIECES_CHAT_TIERS` (fast/smart/premium) only maps to a real id through `ALLOWED_CHAT_MODELS_BY_PROVIDER`, which covers Anthropic, OpenAI, Google, Vertex and Activepieces. Everything else — Bedrock, Azure, Mistral, xAI, DeepSeek and the other OpenAI-compatible vendors — has an empty candidate list. The resolver used to invent an id from the tier (strip the vendor prefix, dots to dashes: `anthropic/claude-haiku-4.5` -> `claude-haiku-4-5`), which Bedrock rejected as "The provided model identifier is invalid" (GIT-1840, Pylon 5836). #15355 removed the invention and left a throw in its place, which was worse: every agent turn on those providers died with `ENTITY_NOT_FOUND: this AI provider key allows no text model a chat turn can run on` before the configured model handled anything (Pylon 6069). **The missing ingredient was never a smarter guess, it was the key's own model list.** `agentHelpers.resolveModelId` is now the single resolver for every surface: it tries our catalogue first and, only when that has nothing to say, reads `aiProviderService.listModels` (process-cached, cleared nightly by cron, already filtered by the key's `modelScope`), preferring a model whose id *contains* the tier's `nativeModelId` — Bedrock spells them `eu.anthropic.claude-sonnet-4-6-...`, so a tier still selects the model it names wherever the vendor hosts it, and otherwise degrades to the first text model the way `pickAllowedModel` already does for an admin-listed catalogue. Picking from a list the provider returned is the opposite of the fabrication #15355 removed. **Two traps, both learned the hard way.** First, an agent surface and generic chat are not symmetric: an agent *carries* a real model id (its picker lists `listModels` output unfiltered, precisely because these providers have no `ALLOWED_CHAT_MODELS_BY_PROVIDER` entry) while chat carries only a tier name, because `ChatModelSelector` falls back to tier options when `getCuratedChatModels` returns `undefined`. Second, **being listed proves the key can see a model, not that the account can invoke it** — so where a model is already settled the fast round reuses it rather than choosing again (`getAgentConfig` passes the model the turn resolved; the tool RPC reads the run's own model off the conversation or the agent via `pinnedModelOf` — and it has to read the *same* half the run did: an agent-page chat runs on `agent.draft` (`agent-conversation-controller.ts`), a Run Agent step on `agent.published`, so reading the wrong one silently lends the tool a stale model whenever the two differ). Resolving the fast tier independently once picked a Haiku the account could not invoke and failed a turn whose configured Sonnet worked fine. A borrowed model still has to pass the key's current `modelScope`, or resolution falls through to the list, which applies it. Nothing crosses the worker, so a rolling deploy has no version-skew window. +- **A chat *tier* is our own idea, so it has no meaning on a provider we ship no curated model list for — resolve it by asking the key what it offers.** `ACTIVEPIECES_CHAT_TIERS` (fast/smart/premium) only maps to a real id through `ALLOWED_CHAT_MODELS_BY_PROVIDER`, which covers Anthropic, OpenAI, Google, Vertex and Activepieces. Everything else — Bedrock, Azure, Mistral, xAI, DeepSeek and the other OpenAI-compatible vendors — has an empty candidate list. The resolver used to invent an id from the tier (strip the vendor prefix, dots to dashes: `anthropic/claude-haiku-4.5` -> `claude-haiku-4-5`), which Bedrock rejected as "The provided model identifier is invalid" (GIT-1840, Pylon 5836). #15355 removed the invention and left a throw in its place, which was worse: every agent turn on those providers died with `ENTITY_NOT_FOUND: this AI provider key allows no text model a chat turn can run on` before the configured model handled anything (Pylon 6069). **The missing ingredient was never a smarter guess, it was the key's own model list.** `agentHelpers.resolveModelId` is now the single resolver for every surface: it tries our catalogue first and, only when that has nothing to say, reads `aiProviderService.listModels` (process-cached, cleared nightly by cron, already filtered by the key's `modelScope`), preferring a model whose id *contains* the tier's `nativeModelId` — Bedrock spells them `eu.anthropic.claude-sonnet-4-6-...`, so a tier still selects the model it names wherever the vendor hosts it, and otherwise degrades to the first text model the way `pickAllowedModel` already does for an admin-listed catalogue. Picking from a list the provider returned is the opposite of the fabrication #15355 removed. **Two traps, both learned the hard way.** First, an agent surface and generic chat are not symmetric: an agent *carries* a real model id (its picker lists `listModels` output unfiltered, precisely because these providers have no `ALLOWED_CHAT_MODELS_BY_PROVIDER` entry) while chat carries only a tier name, because `ChatModelSelector` falls back to tier options when `getCuratedChatModels` returns `undefined`. Second, **being listed proves the key can see a model, not that the account can invoke it** — so where a model is already settled the fast round reuses it rather than choosing again (`getAgentConfig` passes the model the turn resolved; the tool RPC gets the run's own model from the worker, `modelId` on `executePieceTool`). One more trap, found in review on 2026-09-27: a stored name is not the model the run ran on. The worker stamps the resolved *tier id* on an agentless inline run's conversation, so reading it back as a model hands a bare `smart` to a catalogue-less own-key provider, and no rule on the string can save it, because a deployment may be named `smart` too. So nothing is read off the conversation or the agent for the fast round any more: the worker says which model the turn resolved, `modelId` on `ExecutePieceToolRequest` (core-execution), and the API borrows exactly that. The field is required, not optional, because the dispatch gate keeps worker and app on one release, so an older worker never reaches a newer API. Resolving the fast tier independently once picked a Haiku the account could not invoke and failed a turn whose configured Sonnet worked fine. A borrowed model still has to pass the key's current `modelScope`, or resolution falls through to the list, which applies it. Nothing crosses the worker, so a rolling deploy has no version-skew window. - `packages/server/api/src/app/ai/` — backend module: provider controller, service, entity, module, plus the sibling ai-tool-config files - `packages/server/api/src/app/ai/providers/` — per-vendor strategies keyed by `AIProviderName` - `packages/server/api/src/app/platform/billing-provider.ts` — `assertCreditsAndAppSumoNotExceeded` credit gate diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index b3c72cb7ff69..29c60561a18a 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -84,6 +84,7 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **Docs videos belong on `cdn.activepieces.com/videos/docs/`, never in the repo — and `docs/resources/` is the legacy path that suggests otherwise.** Every `