diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index d18862c51e0..bae77686e5f 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2801,6 +2801,52 @@ export function LinqIcon(props: SVGProps) { ) } +export function ClickUpIcon(props: SVGProps) { + const id = useId() + const bodyGradientId = `clickup_body_${id}` + const arrowGradientId = `clickup_arrow_${id}` + return ( + + + + + + + + + + + + + ) +} + export function LinearIcon(props: React.SVGProps) { return ( ) => ( ) +export const NvidiaIcon = (props: SVGProps) => ( + + NVIDIA + + +) + +export const ZaiIcon = (props: SVGProps) => ( + + Z.ai + + + + + +) + export function MetaIcon(props: SVGProps) { const id = useId() const gradient1Id = `meta_gradient_1_${id}` diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index a8e939b23c1..65a7684ef0a 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -34,6 +34,7 @@ import { ClayIcon, ClerkIcon, ClickHouseIcon, + ClickUpIcon, CloudFormationIcon, CloudflareIcon, CloudWatchIcon, @@ -273,6 +274,7 @@ export const blockTypeToIconMap: Record = { clay: ClayIcon, clerk: ClerkIcon, clickhouse: ClickHouseIcon, + clickup: ClickUpIcon, cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, diff --git a/apps/docs/content/docs/en/integrations/clickup-service-account.mdx b/apps/docs/content/docs/en/integrations/clickup-service-account.mdx new file mode 100644 index 00000000000..4d06900ecb4 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/clickup-service-account.mdx @@ -0,0 +1,81 @@ +--- +title: ClickUp API Tokens +description: Connect ClickUp to Sim with a personal API token — ideally created from a dedicated service user for production workflows +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' +import { FAQ } from '@/components/ui/faq' + +ClickUp personal API tokens let your workflows authenticate without an OAuth consent flow. A token gives full parity with the ClickUp API — everything Sim's ClickUp blocks can do over OAuth works with a token. + +Tokens are bound to the user who creates them: every action a workflow takes is attributed to that user, and the token stops working if the user is deactivated or removed from the workspace. For production workflows, create the token from a dedicated service user (e.g. `sim-bot@yourcompany.com`) rather than a personal account. + +## Prerequisites + +A ClickUp account with access to the workspaces your workflows need. Any user can generate a personal API token from their settings. + +## Creating the API Token + + + + Log in as the service user, click your avatar in ClickUp, and open **Settings** + + {/* TODO(screenshot): ClickUp avatar menu with Settings highlighted */} + + + In the sidebar, go to **Apps** (labeled **API Token** in some plans) + + + Click **Generate** to create your personal token + + {/* TODO(screenshot): ClickUp Apps page with the Generate API token button visible */} + + + Copy the token — it starts with `pk_` — and store it somewhere safe. + + + + +The API token carries the creating user's full access to every workspace they belong to. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest. + + +## Adding the API Token to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "ClickUp Service Account" and click it, then click **Add to Sim** and choose **Add API token** + + {/* TODO(screenshot): Integrations page with "ClickUp Service Account" in the service list */} + + + Paste the API token (`pk_...`) and optionally set a display name and description + + {/* TODO(screenshot): Add ClickUp API token dialog with the token filled in */} + + + Click **Add API token**. Sim verifies the token by fetching the authorized user from ClickUp — if it fails, you'll see a specific error explaining what went wrong. + + + +The token is encrypted before being stored. + +## Using the Service Account in Workflows + +Add a ClickUp block to your workflow. In the credential dropdown, your ClickUp service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): ClickUp block in a workflow with the service account selected as the credential */} + +The block calls the ClickUp API (`api.clickup.com`) with the token. Everything the workflow does — creating tasks, adding comments, uploading attachments — is attributed to the user who created the token. + + diff --git a/apps/docs/content/docs/en/integrations/clickup.mdx b/apps/docs/content/docs/en/integrations/clickup.mdx new file mode 100644 index 00000000000..4fc022846f3 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/clickup.mdx @@ -0,0 +1,788 @@ +--- +title: ClickUp +description: Interact with ClickUp +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields. + + + +## Actions + +### `clickup_create_task` + +Create a new task in a ClickUp list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | ID of the list to create the task in | +| `name` | string | Yes | Name of the task | +| `description` | string | No | Plain text description of the task | +| `markdownContent` | string | No | Markdown description of the task \(overrides description\) | +| `status` | string | No | Status to create the task with \(must exist in the list\) | +| `priority` | number | No | Priority: 1 \(urgent\), 2 \(high\), 3 \(normal\), 4 \(low\) | +| `dueDate` | number | No | Due date as a Unix timestamp in milliseconds | +| `dueDateTime` | boolean | No | Whether the due date includes a time of day | +| `startDate` | number | No | Start date as a Unix timestamp in milliseconds | +| `startDateTime` | boolean | No | Whether the start date includes a time of day | +| `assignees` | array | No | User IDs to assign to the task | +| `tags` | array | No | Tag names to apply to the task | +| `timeEstimate` | number | No | Time estimate in milliseconds | +| `points` | number | No | Sprint points for the task | +| `parent` | string | No | Parent task ID to create this task as a subtask | +| `notifyAll` | boolean | No | When true, creation notifications are sent to everyone, including the task creator | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | json | The created task | + +### `clickup_get_task` + +Retrieve a task from ClickUp by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to retrieve | +| `includeSubtasks` | boolean | No | Include subtasks in the response | +| `includeMarkdownDescription` | boolean | No | Return the task description in Markdown format | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | json | The requested task | + +### `clickup_update_task` + +Update an existing task in ClickUp + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to update | +| `name` | string | No | New name for the task | +| `description` | string | No | New plain text description \(use a single space to clear\) | +| `markdownContent` | string | No | New Markdown description \(takes precedence over description\) | +| `status` | string | No | New status for the task \(must exist in the list\) | +| `priority` | number | No | Priority: 1 \(urgent\), 2 \(high\), 3 \(normal\), 4 \(low\) | +| `dueDate` | number | No | New due date as a Unix timestamp in milliseconds | +| `dueDateTime` | boolean | No | Whether the due date includes a time of day | +| `startDate` | number | No | New start date as a Unix timestamp in milliseconds | +| `startDateTime` | boolean | No | Whether the start date includes a time of day | +| `timeEstimate` | number | No | New time estimate in milliseconds | +| `points` | number | No | New sprint points value | +| `parent` | string | No | Parent task ID to move this task under \(cannot be cleared\) | +| `archived` | boolean | No | Set to true to archive the task, false to unarchive | +| `assigneesToAdd` | array | No | User IDs to add as assignees | +| `assigneesToRemove` | array | No | User IDs to remove from assignees | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | json | The updated task | + +### `clickup_delete_task` + +Delete a task from ClickUp + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the deleted task | +| `deleted` | boolean | Whether the task was deleted | + +### `clickup_get_tasks` + +List the tasks in a ClickUp list (100 tasks per page; increment page until an empty result to paginate) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | ID of the list to fetch tasks from | +| `page` | number | No | Page to fetch \(starts at 0\) | +| `orderBy` | string | No | Order by field: id, created, updated, or due_date | +| `reverse` | boolean | No | Return tasks in reverse order | +| `subtasks` | boolean | No | Include subtasks \(excluded by default\) | +| `includeClosed` | boolean | No | Include closed tasks \(excluded by default\) | +| `includeMarkdownDescription` | boolean | No | Return task descriptions in Markdown format | +| `archived` | boolean | No | Return archived tasks | +| `statuses` | array | No | Filter tasks by status names | +| `assignees` | array | No | Filter tasks by assignee user IDs | +| `tags` | array | No | Filter tasks by tag names | +| `dueDateGt` | number | No | Only tasks due after this Unix timestamp in milliseconds | +| `dueDateLt` | number | No | Only tasks due before this Unix timestamp in milliseconds | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tasks` | array | Tasks in the list | + +### `clickup_search_tasks` + +Search tasks across a ClickUp workspace with filters for lists, folders, spaces, statuses, assignees, tags, and due dates (100 tasks per page; increment page until an empty result to paginate) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to search tasks in | +| `page` | number | No | Page to fetch \(starts at 0\) | +| `orderBy` | string | No | Order by field: id, created, updated, or due_date | +| `reverse` | boolean | No | Return tasks in reverse order | +| `subtasks` | boolean | No | Include subtasks \(excluded by default\) | +| `includeClosed` | boolean | No | Include closed tasks \(excluded by default\) | +| `includeMarkdownDescription` | boolean | No | Return task descriptions in Markdown format | +| `listIds` | array | No | Filter by list IDs | +| `spaceIds` | array | No | Filter by space IDs | +| `folderIds` | array | No | Filter by folder IDs | +| `statuses` | array | No | Filter tasks by status names | +| `assignees` | array | No | Filter tasks by assignee user IDs | +| `tags` | array | No | Filter tasks by tag names | +| `dueDateGt` | number | No | Only tasks due after this Unix timestamp in milliseconds | +| `dueDateLt` | number | No | Only tasks due before this Unix timestamp in milliseconds | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tasks` | array | Tasks matching the filters | + +### `clickup_create_comment` + +Add a comment to a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to comment on | +| `commentText` | string | Yes | Content of the comment | +| `assignee` | number | No | User ID to assign the comment to | +| `notifyAll` | boolean | No | When true, comment notifications are sent to everyone, including the comment creator | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the created comment | +| `histId` | string | History ID of the created comment | +| `date` | number | Creation timestamp of the comment \(Unix ms\) | + +### `clickup_get_comments` + +Retrieve comments on a ClickUp task, newest first (25 per page; paginate with start and startId) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to fetch comments from | +| `start` | number | No | Unix timestamp \(ms\) of the reference comment for pagination \(use the date of the last comment from the previous page, together with startId\) | +| `startId` | string | No | ID of the reference comment for pagination \(use the id of the last comment from the previous page, together with start\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `comments` | array | Comments on the task, newest first | + +### `clickup_update_comment` + +Update the content, assignee, or resolved state of a ClickUp task comment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `commentId` | string | Yes | ID of the comment to update | +| `commentText` | string | No | New content for the comment | +| `assignee` | number | No | User ID to assign the comment to | +| `resolved` | boolean | No | Whether the comment is resolved | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the updated comment | +| `updated` | boolean | Whether the comment was updated | + +### `clickup_delete_comment` + +Delete a comment from a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `commentId` | string | Yes | ID of the comment to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the deleted comment | +| `deleted` | boolean | Whether the comment was deleted | + +### `clickup_upload_attachment` + +Upload a file to a ClickUp task as an attachment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to attach the file to | +| `file` | file | Yes | File to attach to the task | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `attachment` | json | The created attachment | +| ↳ `id` | string | Attachment ID | +| ↳ `version` | string | Attachment version | +| ↳ `title` | string | Attachment title | +| ↳ `extension` | string | File extension | +| ↳ `url` | string | URL of the uploaded attachment | +| ↳ `date` | number | Upload timestamp \(Unix ms\) | +| ↳ `thumbnailSmall` | string | Small thumbnail URL | +| ↳ `thumbnailLarge` | string | Large thumbnail URL | +| `files` | file[] | The uploaded attachment file | + +### `clickup_add_tag_to_task` + +Add an existing space tag to a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to tag | +| `tagName` | string | Yes | Name of the tag to add \(must exist in the space\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `taskId` | string | ID of the tagged task | +| `tagName` | string | Name of the tag that was added | + +### `clickup_remove_tag_from_task` + +Remove a tag from a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to remove the tag from | +| `tagName` | string | Yes | Name of the tag to remove | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `taskId` | string | ID of the task | +| `tagName` | string | Name of the tag that was removed | + +### `clickup_get_space_tags` + +List the task tags available in a ClickUp space + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `spaceId` | string | Yes | ID of the space to list tags from | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tags` | array | Tags available in the space | + +### `clickup_get_task_members` + +List the workspace members who have explicit access to a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to list members for | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | Members with explicit access to the task | + +### `clickup_get_list_members` + +List the workspace members who have explicit access to a ClickUp list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | ID of the list to list members for | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | Members with explicit access to the list | + +### `clickup_get_custom_fields` + +List the custom fields accessible in a ClickUp list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | ID of the list to fetch custom fields from | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fields` | array | Custom fields accessible in the list | +| ↳ `id` | string | Custom field ID | +| ↳ `name` | string | Custom field name | +| ↳ `type` | string | Custom field type \(e.g. text, number, drop_down\) | +| ↳ `typeConfig` | json | Type-specific configuration \(e.g. dropdown options\) | +| ↳ `dateCreated` | string | Creation timestamp \(Unix ms\) | +| ↳ `hideFromGuests` | boolean | Whether the field is hidden from guests | + +### `clickup_get_workspaces` + +List the ClickUp workspaces (teams) available to the connected account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workspaces` | array | Workspaces available to the connected account | +| ↳ `id` | string | Workspace ID | +| ↳ `name` | string | Workspace name | +| ↳ `color` | string | Workspace color | +| ↳ `avatar` | string | Workspace avatar URL | + +### `clickup_get_spaces` + +List the spaces in a ClickUp workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to list spaces from | +| `archived` | boolean | No | Return archived spaces | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `spaces` | array | Spaces in the workspace | +| ↳ `id` | string | Space ID | +| ↳ `name` | string | Space name | +| ↳ `private` | boolean | Whether the space is private | +| ↳ `archived` | boolean | Whether the space is archived | +| ↳ `statuses` | array | Task statuses available in the space | +| ↳ `status` | string | Status name | +| ↳ `color` | string | Status color | +| ↳ `type` | string | Status type | + +### `clickup_get_folders` + +List the folders in a ClickUp space + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `spaceId` | string | Yes | ID of the space to list folders from | +| `archived` | boolean | No | Return archived folders | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folders` | array | Folders in the space | + +### `clickup_get_lists` + +List the lists in a ClickUp folder, or the folderless lists in a space when a space ID is provided instead + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `folderId` | string | No | ID of the folder to list lists from \(provide this or spaceId; folderId takes precedence when both are set\) | +| `spaceId` | string | No | ID of the space to list folderless lists from \(provide this or folderId\) | +| `archived` | boolean | No | Return archived lists | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `lists` | array | Lists in the folder or space | + +### `clickup_create_folder` + +Create a new folder in a ClickUp space + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `spaceId` | string | Yes | ID of the space to create the folder in | +| `name` | string | Yes | Name of the folder | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folder` | json | The created folder | + +### `clickup_create_list` + +Create a new list in a ClickUp folder, or a folderless list in a space when a space ID is provided instead + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `folderId` | string | No | ID of the folder to create the list in \(provide this or spaceId; folderId takes precedence when both are set\) | +| `spaceId` | string | No | ID of the space to create a folderless list in \(provide this or folderId\) | +| `name` | string | Yes | Name of the list | +| `content` | string | No | Plain text description of the list | +| `markdownContent` | string | No | Markdown description of the list \(use instead of content\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `list` | json | The created list | + +### `clickup_set_custom_field_value` + +Set the value of a custom field on a ClickUp task (the value shape depends on the field type) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to set the custom field on | +| `fieldId` | string | Yes | UUID of the custom field \(find it with the Get Custom Fields or Get Task operations\) | +| `value` | json | Yes | Value to set. The shape depends on the field type: text/number fields take a plain value, label fields take an array of option UUIDs, dropdown fields take an option UUID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `taskId` | string | ID of the updated task | +| `fieldId` | string | ID of the custom field that was set | + +### `clickup_remove_custom_field_value` + +Remove the value of a custom field from a ClickUp task (does not delete the field itself) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to remove the custom field value from | +| `fieldId` | string | Yes | UUID of the custom field to clear | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `taskId` | string | ID of the updated task | +| `fieldId` | string | ID of the custom field that was cleared | + +### `clickup_create_checklist` + +Add a new checklist to a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to add the checklist to | +| `name` | string | Yes | Name of the checklist | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `checklist` | json | The created checklist | + +### `clickup_update_checklist` + +Rename or reorder a checklist on a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | UUID of the checklist to update | +| `name` | string | No | New name for the checklist | +| `position` | number | No | New position of the checklist on the task \(0 places it first\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the updated checklist | +| `updated` | boolean | Whether the checklist was updated | + +### `clickup_delete_checklist` + +Delete a checklist from a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | UUID of the checklist to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the deleted checklist | +| `deleted` | boolean | Whether the checklist was deleted | + +### `clickup_create_checklist_item` + +Add an item to a checklist on a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | UUID of the checklist to add the item to | +| `name` | string | Yes | Name of the checklist item | +| `assignee` | number | No | User ID to assign the item to | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `checklist` | json | The updated checklist including its items | + +### `clickup_update_checklist_item` + +Update a checklist item on a ClickUp task — rename, assign, resolve, or nest it + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | UUID of the checklist containing the item | +| `checklistItemId` | string | Yes | UUID of the checklist item to update | +| `name` | string | No | New name for the checklist item | +| `assignee` | number | No | User ID to assign the item to | +| `resolved` | boolean | No | Whether the item is resolved | +| `parent` | string | No | UUID of another checklist item to nest this item under | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `checklist` | json | The updated checklist including its items | + +### `clickup_delete_checklist_item` + +Delete an item from a checklist on a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | UUID of the checklist containing the item | +| `checklistItemId` | string | Yes | UUID of the checklist item to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the deleted checklist item | +| `deleted` | boolean | Whether the item was deleted | + +### `clickup_get_time_entries` + +List time entries in a ClickUp workspace within a date range (defaults to the last 30 days for the authenticated user) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to list time entries from | +| `startDate` | number | No | Start of the date range as a Unix timestamp in milliseconds | +| `endDate` | number | No | End of the date range as a Unix timestamp in milliseconds | +| `assignee` | string | No | Filter by user IDs, comma-separated \(requires workspace owner/admin to view others\) | +| `taskId` | string | No | Only entries for this task \(use at most one location filter\) | +| `listId` | string | No | Only entries in this list \(use at most one location filter\) | +| `folderId` | string | No | Only entries in this folder \(use at most one location filter\) | +| `spaceId` | string | No | Only entries in this space \(use at most one location filter\) | +| `includeTaskTags` | boolean | No | Include task tags in the response | +| `includeLocationNames` | boolean | No | Include list, folder, and space names in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntries` | array | Time entries in the date range | + +### `clickup_create_time_entry` + +Create a manual time entry in a ClickUp workspace, optionally linked to a task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to create the entry in | +| `start` | number | Yes | Start of the entry as a Unix timestamp in milliseconds | +| `duration` | number | Yes | Duration of the entry in milliseconds | +| `description` | string | No | Description of the time entry | +| `billable` | boolean | No | Whether the entry is billable | +| `taskId` | string | No | Task ID to associate the entry with | +| `assignee` | number | No | User ID to create the entry for \(workspace owners/admins only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | json | The created time entry | + +### `clickup_update_time_entry` + +Update a time entry in a ClickUp workspace — description, start/end times, task, or billable state + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) the entry belongs to | +| `timerId` | string | Yes | ID of the time entry to update | +| `description` | string | No | New description for the entry | +| `start` | number | No | New start \(Unix ms\); when provided, end must also be provided | +| `end` | number | No | New end \(Unix ms\); when provided, start must also be provided | +| `duration` | number | No | New duration in milliseconds | +| `taskId` | string | No | Task ID to associate the entry with | +| `billable` | boolean | No | Whether the entry is billable | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the updated time entry | +| `updated` | boolean | Whether the entry was updated | + +### `clickup_delete_time_entry` + +Delete a time entry from a ClickUp workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) the entry belongs to | +| `timerId` | string | Yes | ID of the time entry to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | json | The deleted time entry | + +### `clickup_start_timer` + +Start a timer for the authenticated user in a ClickUp workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to start the timer in | +| `taskId` | string | No | Task ID to associate the timer with | +| `description` | string | No | Description of the time entry | +| `billable` | boolean | No | Whether the entry is billable | +| `tags` | array | No | Time entry tag names to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | json | The started time entry \(duration is negative while running\) | + +### `clickup_stop_timer` + +Stop the authenticated user's currently running timer in a ClickUp workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) the timer is running in | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | json | The stopped time entry | + +### `clickup_get_running_timer` + +Get the currently running time entry in a ClickUp workspace (null when no timer is running) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to check | +| `assignee` | number | No | User ID to check instead of the authenticated user \(owners/admins only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | json | The running time entry \(duration is negative while running\); null when no timer is running | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 248715d9577..28522766552 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -36,6 +36,8 @@ "clay", "clerk", "clickhouse", + "clickup", + "clickup-service-account", "cloudflare", "cloudformation", "cloudwatch", diff --git a/apps/sim/app/api/tools/clickup/upload-attachment/route.ts b/apps/sim/app/api/tools/clickup/upload-attachment/route.ts new file mode 100644 index 00000000000..bcb4d8ab435 --- /dev/null +++ b/apps/sim/app/api/tools/clickup/upload-attachment/route.ts @@ -0,0 +1,131 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { clickupUploadAttachmentContract } from '@/lib/api/contracts/tools/clickup' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpAttachment, +} from '@/tools/clickup/shared' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('ClickUpUploadAttachmentAPI') + +const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024 + +function uploadSizeError(bytes: number): NextResponse { + const sizeMB = (bytes / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { success: false, error: `File size (${sizeMB}MB) exceeds upload limit of 100MB` }, + { status: 400 } + ) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(clickupUploadAttachmentContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const userFiles = processFilesToUserFiles([params.file as RawFileInput], requestId, logger) + if (userFiles.length === 0) { + return NextResponse.json( + { success: false, error: 'No valid file provided for upload' }, + { status: 400 } + ) + } + + const userFile = userFiles[0] + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + + if (userFile.size > MAX_UPLOAD_SIZE_BYTES) { + return uploadSizeError(userFile.size) + } + + let buffer: Buffer + let downloadedContentType = '' + try { + const result = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_UPLOAD_SIZE_BYTES, + }) + buffer = result.buffer + downloadedContentType = result.contentType + } catch (error) { + const notReady = docNotReadyResponse(error) + if (notReady) return notReady + if (error instanceof PayloadSizeLimitError) { + return uploadSizeError(error.observedBytes ?? userFile.size) + } + throw error + } + + if (buffer.length > MAX_UPLOAD_SIZE_BYTES) { + return uploadSizeError(buffer.length) + } + + const formData = new FormData() + const blob = new Blob([new Uint8Array(buffer)], { + type: downloadedContentType || userFile.type || 'application/octet-stream', + }) + formData.append('attachment', blob, userFile.name) + + const url = `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/attachment` + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: clickupAuthorizationHeader(params.accessToken), + }, + body: formData, + }) + + const data: unknown = await response.json().catch(() => null) + if (!response.ok) { + const message = extractClickUpErrorMessage( + response, + data, + 'Failed to upload ClickUp attachment' + ) + logger.error(`[${requestId}] ClickUp attachment upload failed`, { + status: response.status, + message, + }) + return NextResponse.json({ success: false, error: message }, { status: response.status }) + } + + return NextResponse.json({ + success: true, + output: { + attachment: mapClickUpAttachment(data), + files: userFiles, + }, + }) + } catch (error) { + logger.error(`[${requestId}] ClickUp attachment upload error`, error) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Internal server error') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/blocks/blocks/clickup.ts b/apps/sim/blocks/blocks/clickup.ts new file mode 100644 index 00000000000..9a0281b18e6 --- /dev/null +++ b/apps/sim/blocks/blocks/clickup.ts @@ -0,0 +1,1698 @@ +import { ClickUpIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' +import type { ClickUpResponse } from '@/tools/clickup/types' + +const TIMESTAMP_WAND_PROMPT = `Generate a Unix timestamp in milliseconds based on the user's description. +Examples: +- "tomorrow at noon" -> Calculate tomorrow at 12:00 and return its Unix ms timestamp +- "next Friday" -> Calculate next Friday at 00:00 and return its Unix ms timestamp +- "in 3 days" -> Calculate 3 days from now and return its Unix ms timestamp + +Return ONLY the numeric timestamp in milliseconds - no explanations, no quotes, no extra text.` + +function splitCommaSeparated(value: unknown): string[] | undefined { + if (typeof value !== 'string') return undefined + const parts = value + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0) + return parts.length > 0 ? parts : undefined +} + +function splitCommaSeparatedNumbers(value: unknown): number[] | undefined { + const parts = splitCommaSeparated(value) + if (!parts) return undefined + const numbers = parts.map((part) => Number(part)).filter((num) => Number.isFinite(num)) + return numbers.length > 0 ? numbers : undefined +} + +function optionalNumber(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +/** + * Parses a custom field value from the block input. JSON values (objects, + * arrays, numbers, booleans, quoted strings) are decoded so structured and + * typed fields (labels, progress, number, checkbox) receive the right wire + * type; anything that isn't valid JSON is passed through as a plain string, + * which text and dropdown fields accept. + */ +function parseCustomFieldValue(value: unknown): unknown { + if (typeof value !== 'string') return value + const trimmed = value.trim() + if (trimmed.length === 0) return value + try { + return JSON.parse(trimmed) + } catch { + return value + } +} + +function billableFromAction(action: unknown): boolean | undefined { + return action === 'billable' ? true : action === 'non_billable' ? false : undefined +} + +export const ClickUpBlock: BlockConfig = { + type: 'clickup', + name: 'ClickUp', + description: 'Interact with ClickUp', + authMode: AuthMode.OAuth, + longDescription: + 'Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields.', + docsLink: 'https://docs.sim.ai/integrations/clickup', + category: 'tools', + integrationType: IntegrationType.Productivity, + bgColor: '#FFFFFF', + icon: ClickUpIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Create Task', id: 'create_task' }, + { label: 'Get Task', id: 'get_task' }, + { label: 'Update Task', id: 'update_task' }, + { label: 'Delete Task', id: 'delete_task' }, + { label: 'Get Tasks', id: 'get_tasks' }, + { label: 'Search Tasks', id: 'search_tasks' }, + { label: 'Create Comment', id: 'create_comment' }, + { label: 'Get Comments', id: 'get_comments' }, + { label: 'Update Comment', id: 'update_comment' }, + { label: 'Delete Comment', id: 'delete_comment' }, + { label: 'Upload Attachment', id: 'upload_attachment' }, + { label: 'Add Tag to Task', id: 'add_tag_to_task' }, + { label: 'Remove Tag from Task', id: 'remove_tag_from_task' }, + { label: 'Get Space Tags', id: 'get_space_tags' }, + { label: 'Get Task Members', id: 'get_task_members' }, + { label: 'Get List Members', id: 'get_list_members' }, + { label: 'Get Custom Fields', id: 'get_custom_fields' }, + { label: 'Set Custom Field Value', id: 'set_custom_field_value' }, + { label: 'Remove Custom Field Value', id: 'remove_custom_field_value' }, + { label: 'Create Checklist', id: 'create_checklist' }, + { label: 'Update Checklist', id: 'update_checklist' }, + { label: 'Delete Checklist', id: 'delete_checklist' }, + { label: 'Create Checklist Item', id: 'create_checklist_item' }, + { label: 'Update Checklist Item', id: 'update_checklist_item' }, + { label: 'Delete Checklist Item', id: 'delete_checklist_item' }, + { label: 'Get Time Entries', id: 'get_time_entries' }, + { label: 'Create Time Entry', id: 'create_time_entry' }, + { label: 'Update Time Entry', id: 'update_time_entry' }, + { label: 'Delete Time Entry', id: 'delete_time_entry' }, + { label: 'Start Timer', id: 'start_timer' }, + { label: 'Stop Timer', id: 'stop_timer' }, + { label: 'Get Running Timer', id: 'get_running_timer' }, + { label: 'Get Workspaces', id: 'get_workspaces' }, + { label: 'Get Spaces', id: 'get_spaces' }, + { label: 'Get Folders', id: 'get_folders' }, + { label: 'Get Lists', id: 'get_lists' }, + { label: 'Create Folder', id: 'create_folder' }, + { label: 'Create List', id: 'create_list' }, + ], + value: () => 'create_task', + }, + { + id: 'credential', + title: 'ClickUp Account', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + mode: 'basic', + required: true, + serviceId: 'clickup', + requiredScopes: getScopesForService('clickup'), + placeholder: 'Select ClickUp account', + }, + { + id: 'manualCredential', + title: 'ClickUp Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, + { + id: 'workspaceId', + title: 'Workspace ID', + type: 'short-input', + required: true, + placeholder: 'Enter workspace (team) ID', + condition: { + field: 'operation', + value: [ + 'search_tasks', + 'get_spaces', + 'get_time_entries', + 'create_time_entry', + 'update_time_entry', + 'delete_time_entry', + 'start_timer', + 'stop_timer', + 'get_running_timer', + ], + }, + }, + { + id: 'spaceId', + title: 'Space ID', + type: 'short-input', + required: true, + placeholder: 'Enter space ID', + condition: { + field: 'operation', + value: ['get_folders', 'create_folder', 'get_space_tags'], + }, + }, + { + id: 'listParent', + title: 'Location', + type: 'dropdown', + options: [ + { label: 'Folder', id: 'folder' }, + { label: 'Space (folderless)', id: 'space' }, + ], + value: () => 'folder', + condition: { + field: 'operation', + value: ['get_lists', 'create_list'], + }, + }, + { + id: 'folderId', + title: 'Folder ID', + type: 'short-input', + required: true, + placeholder: 'Enter folder ID', + condition: { + field: 'operation', + value: ['get_lists', 'create_list'], + and: { field: 'listParent', value: 'folder' }, + }, + }, + { + id: 'listSpaceId', + title: 'Space ID', + type: 'short-input', + required: true, + placeholder: 'Enter space ID', + condition: { + field: 'operation', + value: ['get_lists', 'create_list'], + and: { field: 'listParent', value: 'space' }, + }, + }, + { + id: 'listId', + title: 'List ID', + type: 'short-input', + required: true, + placeholder: 'Enter list ID', + condition: { + field: 'operation', + value: ['create_task', 'get_tasks', 'get_list_members', 'get_custom_fields'], + }, + }, + { + id: 'taskId', + title: 'Task ID', + type: 'short-input', + required: true, + placeholder: 'Enter task ID', + condition: { + field: 'operation', + value: [ + 'get_task', + 'update_task', + 'delete_task', + 'create_comment', + 'get_comments', + 'upload_attachment', + 'add_tag_to_task', + 'remove_tag_from_task', + 'get_task_members', + 'set_custom_field_value', + 'remove_custom_field_value', + 'create_checklist', + ], + }, + }, + { + id: 'commentId', + title: 'Comment ID', + type: 'short-input', + required: true, + placeholder: 'Enter comment ID', + condition: { + field: 'operation', + value: ['update_comment', 'delete_comment'], + }, + }, + { + id: 'name', + title: 'Name', + type: 'short-input', + required: { + field: 'operation', + value: [ + 'create_task', + 'create_folder', + 'create_list', + 'create_checklist', + 'create_checklist_item', + ], + }, + placeholder: 'Enter a name', + condition: { + field: 'operation', + value: [ + 'create_task', + 'update_task', + 'create_folder', + 'create_list', + 'create_checklist', + 'update_checklist', + 'create_checklist_item', + 'update_checklist_item', + ], + }, + }, + { + id: 'description', + title: 'Description', + type: 'long-input', + placeholder: 'Enter task description', + condition: { + field: 'operation', + value: ['create_task', 'update_task'], + }, + }, + { + id: 'markdownContent', + title: 'Markdown Description', + type: 'long-input', + mode: 'advanced', + placeholder: 'Markdown description (used instead of the plain description)', + condition: { + field: 'operation', + value: ['create_task', 'update_task', 'create_list'], + }, + }, + { + id: 'status', + title: 'Status', + type: 'short-input', + placeholder: 'Enter a status that exists in the list (e.g. "in progress")', + condition: { + field: 'operation', + value: ['create_task', 'update_task'], + }, + }, + { + id: 'priority', + title: 'Priority', + type: 'dropdown', + options: [ + { label: 'None', id: 'none' }, + { label: 'Urgent', id: '1' }, + { label: 'High', id: '2' }, + { label: 'Normal', id: '3' }, + { label: 'Low', id: '4' }, + ], + value: () => 'none', + condition: { + field: 'operation', + value: ['create_task', 'update_task'], + }, + }, + { + id: 'dueDate', + title: 'Due Date', + type: 'short-input', + placeholder: 'Unix timestamp in milliseconds', + condition: { + field: 'operation', + value: ['create_task', 'update_task'], + }, + wandConfig: { + enabled: true, + prompt: TIMESTAMP_WAND_PROMPT, + placeholder: 'Describe the due date (e.g., "tomorrow", "next Friday")...', + generationType: 'timestamp', + }, + }, + { + id: 'startDate', + title: 'Start Date', + type: 'short-input', + mode: 'advanced', + placeholder: 'Unix timestamp in milliseconds', + condition: { + field: 'operation', + value: ['create_task', 'update_task'], + }, + wandConfig: { + enabled: true, + prompt: TIMESTAMP_WAND_PROMPT, + placeholder: 'Describe the start date (e.g., "today", "next Monday")...', + generationType: 'timestamp', + }, + }, + { + id: 'assignees', + title: 'Assignees', + type: 'short-input', + placeholder: 'Comma-separated user IDs (e.g. 183, 184)', + condition: { + field: 'operation', + value: ['create_task', 'get_tasks', 'search_tasks'], + }, + }, + { + id: 'assigneesToAdd', + title: 'Add Assignees', + type: 'short-input', + mode: 'advanced', + placeholder: 'Comma-separated user IDs to add', + condition: { + field: 'operation', + value: ['update_task'], + }, + }, + { + id: 'assigneesToRemove', + title: 'Remove Assignees', + type: 'short-input', + mode: 'advanced', + placeholder: 'Comma-separated user IDs to remove', + condition: { + field: 'operation', + value: ['update_task'], + }, + }, + { + id: 'tags', + title: 'Tags', + type: 'short-input', + placeholder: 'Comma-separated tag names', + condition: { + field: 'operation', + value: ['create_task', 'get_tasks', 'search_tasks'], + }, + }, + { + id: 'timeEstimate', + title: 'Time Estimate', + type: 'short-input', + mode: 'advanced', + placeholder: 'Time estimate in milliseconds', + condition: { + field: 'operation', + value: ['create_task', 'update_task'], + }, + }, + { + id: 'points', + title: 'Sprint Points', + type: 'short-input', + mode: 'advanced', + placeholder: 'Sprint points (number)', + condition: { + field: 'operation', + value: ['create_task', 'update_task'], + }, + }, + { + id: 'parent', + title: 'Parent Task ID', + type: 'short-input', + mode: 'advanced', + placeholder: 'Parent task ID (creates a subtask)', + condition: { + field: 'operation', + value: ['create_task', 'update_task'], + }, + }, + { + id: 'dueDateTime', + title: 'Due Date Has Time', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['create_task', 'update_task'], + }, + }, + { + id: 'startDateTime', + title: 'Start Date Has Time', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['create_task', 'update_task'], + }, + }, + { + id: 'notifyAll', + title: 'Notify All', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['create_task', 'create_comment'], + }, + }, + { + id: 'archiveAction', + title: 'Archive', + type: 'dropdown', + mode: 'advanced', + options: [ + { label: 'No change', id: 'none' }, + { label: 'Archive', id: 'archive' }, + { label: 'Unarchive', id: 'unarchive' }, + ], + value: () => 'none', + condition: { + field: 'operation', + value: ['update_task'], + }, + }, + { + id: 'includeSubtasks', + title: 'Include Subtasks', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_task'], + }, + }, + { + id: 'includeMarkdownDescription', + title: 'Return Markdown Description', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_task', 'get_tasks', 'search_tasks'], + }, + }, + { + id: 'page', + title: 'Page', + type: 'short-input', + mode: 'advanced', + placeholder: 'Page to fetch (starts at 0)', + condition: { + field: 'operation', + value: ['get_tasks', 'search_tasks'], + }, + }, + { + id: 'orderBy', + title: 'Order By', + type: 'dropdown', + mode: 'advanced', + options: [ + { label: 'Default (created)', id: 'none' }, + { label: 'Created', id: 'created' }, + { label: 'Updated', id: 'updated' }, + { label: 'Due date', id: 'due_date' }, + { label: 'ID', id: 'id' }, + ], + value: () => 'none', + condition: { + field: 'operation', + value: ['get_tasks', 'search_tasks'], + }, + }, + { + id: 'reverse', + title: 'Reverse Order', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_tasks', 'search_tasks'], + }, + }, + { + id: 'subtasks', + title: 'Include Subtasks', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_tasks', 'search_tasks'], + }, + }, + { + id: 'includeClosed', + title: 'Include Closed Tasks', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_tasks', 'search_tasks'], + }, + }, + { + id: 'archived', + title: 'Archived Only', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_tasks', 'get_spaces', 'get_folders', 'get_lists'], + }, + }, + { + id: 'statuses', + title: 'Statuses', + type: 'short-input', + mode: 'advanced', + placeholder: 'Comma-separated status names to filter by', + condition: { + field: 'operation', + value: ['get_tasks', 'search_tasks'], + }, + }, + { + id: 'dueDateGt', + title: 'Due After', + type: 'short-input', + mode: 'advanced', + placeholder: 'Unix timestamp in milliseconds', + condition: { + field: 'operation', + value: ['get_tasks', 'search_tasks'], + }, + wandConfig: { + enabled: true, + prompt: TIMESTAMP_WAND_PROMPT, + placeholder: 'Describe the earliest due date (e.g., "start of this week")...', + generationType: 'timestamp', + }, + }, + { + id: 'dueDateLt', + title: 'Due Before', + type: 'short-input', + mode: 'advanced', + placeholder: 'Unix timestamp in milliseconds', + condition: { + field: 'operation', + value: ['get_tasks', 'search_tasks'], + }, + wandConfig: { + enabled: true, + prompt: TIMESTAMP_WAND_PROMPT, + placeholder: 'Describe the latest due date (e.g., "end of next week")...', + generationType: 'timestamp', + }, + }, + { + id: 'listIds', + title: 'List IDs', + type: 'short-input', + mode: 'advanced', + placeholder: 'Comma-separated list IDs to filter by', + condition: { + field: 'operation', + value: ['search_tasks'], + }, + }, + { + id: 'spaceIds', + title: 'Space IDs', + type: 'short-input', + mode: 'advanced', + placeholder: 'Comma-separated space IDs to filter by', + condition: { + field: 'operation', + value: ['search_tasks'], + }, + }, + { + id: 'folderIds', + title: 'Folder IDs', + type: 'short-input', + mode: 'advanced', + placeholder: 'Comma-separated folder IDs to filter by', + condition: { + field: 'operation', + value: ['search_tasks'], + }, + }, + { + id: 'commentText', + title: 'Comment Text', + type: 'long-input', + required: { field: 'operation', value: ['create_comment'] }, + placeholder: 'Enter comment text', + condition: { + field: 'operation', + value: ['create_comment', 'update_comment'], + }, + }, + { + id: 'commentAssignee', + title: 'Comment Assignee', + type: 'short-input', + mode: 'advanced', + placeholder: 'User ID to assign the comment to', + condition: { + field: 'operation', + value: ['create_comment', 'update_comment'], + }, + }, + { + id: 'resolvedAction', + title: 'Resolved', + type: 'dropdown', + mode: 'advanced', + options: [ + { label: 'No change', id: 'none' }, + { label: 'Resolve', id: 'resolve' }, + { label: 'Unresolve', id: 'unresolve' }, + ], + value: () => 'none', + condition: { + field: 'operation', + value: ['update_comment', 'update_checklist_item'], + }, + }, + { + id: 'start', + title: 'Start Timestamp', + type: 'short-input', + mode: 'advanced', + placeholder: 'Unix ms of the last comment from the previous page', + condition: { + field: 'operation', + value: ['get_comments'], + }, + }, + { + id: 'startId', + title: 'Start Comment ID', + type: 'short-input', + mode: 'advanced', + placeholder: 'ID of the last comment from the previous page', + condition: { + field: 'operation', + value: ['get_comments'], + }, + }, + { + id: 'tagName', + title: 'Tag Name', + type: 'short-input', + required: true, + placeholder: 'Enter tag name', + condition: { + field: 'operation', + value: ['add_tag_to_task', 'remove_tag_from_task'], + }, + }, + { + id: 'content', + title: 'List Description', + type: 'long-input', + placeholder: 'Enter list description', + condition: { + field: 'operation', + value: ['create_list'], + }, + }, + { + id: 'fieldId', + title: 'Custom Field ID', + type: 'short-input', + required: true, + placeholder: 'Enter custom field UUID', + condition: { + field: 'operation', + value: ['set_custom_field_value', 'remove_custom_field_value'], + }, + }, + { + id: 'fieldValue', + title: 'Value', + type: 'long-input', + required: true, + placeholder: 'Plain value, or JSON for structured field types', + condition: { + field: 'operation', + value: ['set_custom_field_value'], + }, + wandConfig: { + enabled: true, + prompt: `Generate the value for a ClickUp custom field. The shape depends on the field type: +- Text / short text: a plain string +- Number: a number +- Drop down: the option UUID as a string +- Labels: a JSON array of option UUIDs +- Date: a Unix timestamp in milliseconds + +Return ONLY the value (plain string, number, or JSON) - no explanations, no extra text.`, + placeholder: 'Describe the value to set...', + generationType: 'json-object', + }, + }, + { + id: 'checklistId', + title: 'Checklist ID', + type: 'short-input', + required: true, + placeholder: 'Enter checklist UUID', + condition: { + field: 'operation', + value: [ + 'update_checklist', + 'delete_checklist', + 'create_checklist_item', + 'update_checklist_item', + 'delete_checklist_item', + ], + }, + }, + { + id: 'checklistItemId', + title: 'Checklist Item ID', + type: 'short-input', + required: true, + placeholder: 'Enter checklist item UUID', + condition: { + field: 'operation', + value: ['update_checklist_item', 'delete_checklist_item'], + }, + }, + { + id: 'position', + title: 'Position', + type: 'short-input', + mode: 'advanced', + placeholder: 'New position on the task (0 places it first)', + condition: { + field: 'operation', + value: ['update_checklist'], + }, + }, + { + id: 'itemAssignee', + title: 'Item Assignee', + type: 'short-input', + mode: 'advanced', + placeholder: 'User ID to assign the item to', + condition: { + field: 'operation', + value: ['create_checklist_item', 'update_checklist_item'], + }, + }, + { + id: 'itemParent', + title: 'Parent Item ID', + type: 'short-input', + mode: 'advanced', + placeholder: 'Checklist item UUID to nest this item under', + condition: { + field: 'operation', + value: ['update_checklist_item'], + }, + }, + { + id: 'timerId', + title: 'Time Entry ID', + type: 'short-input', + required: true, + placeholder: 'Enter time entry ID', + condition: { + field: 'operation', + value: ['update_time_entry', 'delete_time_entry'], + }, + }, + { + id: 'entryStart', + title: 'Start Time', + type: 'short-input', + required: { field: 'operation', value: ['create_time_entry'] }, + placeholder: 'Unix timestamp in milliseconds', + condition: { + field: 'operation', + value: ['create_time_entry', 'update_time_entry'], + }, + wandConfig: { + enabled: true, + prompt: TIMESTAMP_WAND_PROMPT, + placeholder: 'Describe the start time (e.g., "today at 9am")...', + generationType: 'timestamp', + }, + }, + { + id: 'entryEnd', + title: 'End Time', + type: 'short-input', + placeholder: 'Unix timestamp in milliseconds (required when changing the start time)', + condition: { + field: 'operation', + value: ['update_time_entry'], + }, + wandConfig: { + enabled: true, + prompt: TIMESTAMP_WAND_PROMPT, + placeholder: 'Describe the end time (e.g., "today at 5pm")...', + generationType: 'timestamp', + }, + }, + { + id: 'entryDuration', + title: 'Duration', + type: 'short-input', + required: { field: 'operation', value: ['create_time_entry'] }, + placeholder: 'Duration in milliseconds', + condition: { + field: 'operation', + value: ['create_time_entry', 'update_time_entry'], + }, + }, + { + id: 'entryDescription', + title: 'Entry Description', + type: 'short-input', + placeholder: 'Description of the time entry', + condition: { + field: 'operation', + value: ['create_time_entry', 'update_time_entry', 'start_timer'], + }, + }, + { + id: 'billableAction', + title: 'Billable', + type: 'dropdown', + mode: 'advanced', + options: [ + { label: 'No change', id: 'none' }, + { label: 'Billable', id: 'billable' }, + { label: 'Not billable', id: 'non_billable' }, + ], + value: () => 'none', + condition: { + field: 'operation', + value: ['create_time_entry', 'update_time_entry', 'start_timer'], + }, + }, + { + id: 'timerTaskId', + title: 'Task ID', + type: 'short-input', + placeholder: 'Task ID to associate the entry with', + condition: { + field: 'operation', + value: ['create_time_entry', 'update_time_entry', 'start_timer'], + }, + }, + { + id: 'timerTags', + title: 'Entry Tags', + type: 'short-input', + mode: 'advanced', + placeholder: 'Comma-separated time entry tag names', + condition: { + field: 'operation', + value: ['start_timer'], + }, + }, + { + id: 'timerAssignee', + title: 'Assignees', + type: 'short-input', + mode: 'advanced', + placeholder: 'Comma-separated user IDs (owners/admins only)', + condition: { + field: 'operation', + value: ['get_time_entries'], + }, + }, + { + id: 'entryAssignee', + title: 'Assignee', + type: 'short-input', + mode: 'advanced', + placeholder: 'Single user ID (owners/admins only)', + condition: { + field: 'operation', + value: ['create_time_entry', 'get_running_timer'], + }, + }, + { + id: 'timeStartDate', + title: 'From', + type: 'short-input', + mode: 'advanced', + placeholder: 'Unix timestamp in milliseconds', + condition: { + field: 'operation', + value: ['get_time_entries'], + }, + wandConfig: { + enabled: true, + prompt: TIMESTAMP_WAND_PROMPT, + placeholder: 'Describe the range start (e.g., "start of this month")...', + generationType: 'timestamp', + }, + }, + { + id: 'timeEndDate', + title: 'To', + type: 'short-input', + mode: 'advanced', + placeholder: 'Unix timestamp in milliseconds', + condition: { + field: 'operation', + value: ['get_time_entries'], + }, + wandConfig: { + enabled: true, + prompt: TIMESTAMP_WAND_PROMPT, + placeholder: 'Describe the range end (e.g., "now")...', + generationType: 'timestamp', + }, + }, + { + id: 'timeLocationType', + title: 'Filter By Location', + type: 'dropdown', + mode: 'advanced', + options: [ + { label: 'No location filter', id: 'none' }, + { label: 'Task', id: 'task' }, + { label: 'List', id: 'list' }, + { label: 'Folder', id: 'folder' }, + { label: 'Space', id: 'space' }, + ], + value: () => 'none', + condition: { + field: 'operation', + value: ['get_time_entries'], + }, + }, + { + id: 'timeLocationId', + title: 'Location ID', + type: 'short-input', + mode: 'advanced', + placeholder: 'ID of the task, list, folder, or space', + condition: { + field: 'operation', + value: ['get_time_entries'], + and: { field: 'timeLocationType', value: 'none', not: true }, + }, + }, + { + id: 'includeTaskTags', + title: 'Include Task Tags', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_time_entries'], + }, + }, + { + id: 'includeLocationNames', + title: 'Include Location Names', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_time_entries'], + }, + }, + { + id: 'attachmentFile', + title: 'File', + type: 'file-upload', + canonicalParamId: 'file', + placeholder: 'Upload file', + mode: 'basic', + multiple: false, + required: true, + condition: { + field: 'operation', + value: ['upload_attachment'], + }, + }, + { + id: 'fileReference', + title: 'File', + type: 'short-input', + canonicalParamId: 'file', + placeholder: 'File reference from a previous block', + mode: 'advanced', + required: true, + condition: { + field: 'operation', + value: ['upload_attachment'], + }, + }, + ], + tools: { + access: [ + 'clickup_create_task', + 'clickup_get_task', + 'clickup_update_task', + 'clickup_delete_task', + 'clickup_get_tasks', + 'clickup_search_tasks', + 'clickup_create_comment', + 'clickup_get_comments', + 'clickup_update_comment', + 'clickup_delete_comment', + 'clickup_upload_attachment', + 'clickup_add_tag_to_task', + 'clickup_remove_tag_from_task', + 'clickup_get_space_tags', + 'clickup_get_task_members', + 'clickup_get_list_members', + 'clickup_get_custom_fields', + 'clickup_get_workspaces', + 'clickup_get_spaces', + 'clickup_get_folders', + 'clickup_get_lists', + 'clickup_create_folder', + 'clickup_create_list', + 'clickup_set_custom_field_value', + 'clickup_remove_custom_field_value', + 'clickup_create_checklist', + 'clickup_update_checklist', + 'clickup_delete_checklist', + 'clickup_create_checklist_item', + 'clickup_update_checklist_item', + 'clickup_delete_checklist_item', + 'clickup_get_time_entries', + 'clickup_create_time_entry', + 'clickup_update_time_entry', + 'clickup_delete_time_entry', + 'clickup_start_timer', + 'clickup_stop_timer', + 'clickup_get_running_timer', + ], + config: { + tool: (params) => `clickup_${params.operation}`, + params: (params) => { + const { oauthCredential, operation } = params + + const baseParams = { + accessToken: oauthCredential?.accessToken, + } + + const priorityValue = + params.priority && params.priority !== 'none' ? Number(params.priority) : undefined + + switch (operation) { + case 'create_task': + return { + ...baseParams, + listId: params.listId, + name: params.name, + description: params.description || undefined, + markdownContent: params.markdownContent || undefined, + status: params.status || undefined, + priority: priorityValue, + dueDate: optionalNumber(params.dueDate), + dueDateTime: + optionalNumber(params.dueDate) !== undefined + ? Boolean(params.dueDateTime) + : undefined, + startDate: optionalNumber(params.startDate), + startDateTime: + optionalNumber(params.startDate) !== undefined + ? Boolean(params.startDateTime) + : undefined, + assignees: splitCommaSeparatedNumbers(params.assignees), + tags: splitCommaSeparated(params.tags), + timeEstimate: optionalNumber(params.timeEstimate), + points: optionalNumber(params.points), + parent: params.parent || undefined, + notifyAll: params.notifyAll ? true : undefined, + } + case 'get_task': + return { + ...baseParams, + taskId: params.taskId, + includeSubtasks: params.includeSubtasks ? true : undefined, + includeMarkdownDescription: params.includeMarkdownDescription ? true : undefined, + } + case 'update_task': + return { + ...baseParams, + taskId: params.taskId, + name: params.name || undefined, + description: params.description || undefined, + markdownContent: params.markdownContent || undefined, + status: params.status || undefined, + priority: priorityValue, + dueDate: optionalNumber(params.dueDate), + dueDateTime: + optionalNumber(params.dueDate) !== undefined + ? Boolean(params.dueDateTime) + : undefined, + startDate: optionalNumber(params.startDate), + startDateTime: + optionalNumber(params.startDate) !== undefined + ? Boolean(params.startDateTime) + : undefined, + timeEstimate: optionalNumber(params.timeEstimate), + points: optionalNumber(params.points), + parent: params.parent || undefined, + assigneesToAdd: splitCommaSeparatedNumbers(params.assigneesToAdd), + assigneesToRemove: splitCommaSeparatedNumbers(params.assigneesToRemove), + archived: + params.archiveAction === 'archive' + ? true + : params.archiveAction === 'unarchive' + ? false + : undefined, + } + case 'delete_task': + return { + ...baseParams, + taskId: params.taskId, + } + case 'get_tasks': + return { + ...baseParams, + listId: params.listId, + page: optionalNumber(params.page), + orderBy: params.orderBy && params.orderBy !== 'none' ? params.orderBy : undefined, + reverse: params.reverse ? true : undefined, + subtasks: params.subtasks ? true : undefined, + includeClosed: params.includeClosed ? true : undefined, + includeMarkdownDescription: params.includeMarkdownDescription ? true : undefined, + archived: params.archived ? true : undefined, + statuses: splitCommaSeparated(params.statuses), + assignees: splitCommaSeparated(params.assignees), + tags: splitCommaSeparated(params.tags), + dueDateGt: optionalNumber(params.dueDateGt), + dueDateLt: optionalNumber(params.dueDateLt), + } + case 'search_tasks': + return { + ...baseParams, + workspaceId: params.workspaceId, + page: optionalNumber(params.page), + orderBy: params.orderBy && params.orderBy !== 'none' ? params.orderBy : undefined, + reverse: params.reverse ? true : undefined, + subtasks: params.subtasks ? true : undefined, + includeClosed: params.includeClosed ? true : undefined, + includeMarkdownDescription: params.includeMarkdownDescription ? true : undefined, + listIds: splitCommaSeparated(params.listIds), + spaceIds: splitCommaSeparated(params.spaceIds), + folderIds: splitCommaSeparated(params.folderIds), + statuses: splitCommaSeparated(params.statuses), + assignees: splitCommaSeparated(params.assignees), + tags: splitCommaSeparated(params.tags), + dueDateGt: optionalNumber(params.dueDateGt), + dueDateLt: optionalNumber(params.dueDateLt), + } + case 'create_comment': + return { + ...baseParams, + taskId: params.taskId, + commentText: params.commentText, + assignee: optionalNumber(params.commentAssignee), + notifyAll: params.notifyAll ? true : undefined, + } + case 'get_comments': + return { + ...baseParams, + taskId: params.taskId, + start: optionalNumber(params.start), + startId: params.startId || undefined, + } + case 'update_comment': + return { + ...baseParams, + commentId: params.commentId, + commentText: params.commentText || undefined, + assignee: optionalNumber(params.commentAssignee), + resolved: + params.resolvedAction === 'resolve' + ? true + : params.resolvedAction === 'unresolve' + ? false + : undefined, + } + case 'delete_comment': + return { + ...baseParams, + commentId: params.commentId, + } + case 'upload_attachment': { + const normalizedFile = normalizeFileInput(params.file, { single: true }) + if (!normalizedFile) { + throw new Error('An attachment file is required.') + } + return { + ...baseParams, + taskId: params.taskId, + file: normalizedFile, + } + } + case 'add_tag_to_task': + case 'remove_tag_from_task': + return { + ...baseParams, + taskId: params.taskId, + tagName: params.tagName, + } + case 'get_space_tags': + return { + ...baseParams, + spaceId: params.spaceId, + } + case 'get_task_members': + return { + ...baseParams, + taskId: params.taskId, + } + case 'get_list_members': + return { + ...baseParams, + listId: params.listId, + } + case 'get_custom_fields': + return { + ...baseParams, + listId: params.listId, + } + case 'get_workspaces': + return { + ...baseParams, + } + case 'get_spaces': + return { + ...baseParams, + workspaceId: params.workspaceId, + archived: params.archived ? true : undefined, + } + case 'get_folders': + return { + ...baseParams, + spaceId: params.spaceId, + archived: params.archived ? true : undefined, + } + case 'get_lists': + return { + ...baseParams, + folderId: params.listParent === 'space' ? undefined : params.folderId || undefined, + spaceId: params.listParent === 'space' ? params.listSpaceId || undefined : undefined, + archived: params.archived ? true : undefined, + } + case 'create_folder': + return { + ...baseParams, + spaceId: params.spaceId, + name: params.name, + } + case 'create_list': + return { + ...baseParams, + folderId: params.listParent === 'space' ? undefined : params.folderId || undefined, + spaceId: params.listParent === 'space' ? params.listSpaceId || undefined : undefined, + name: params.name, + content: params.content || undefined, + markdownContent: params.markdownContent || undefined, + } + case 'set_custom_field_value': + return { + ...baseParams, + taskId: params.taskId, + fieldId: params.fieldId, + value: parseCustomFieldValue(params.fieldValue), + } + case 'remove_custom_field_value': + return { + ...baseParams, + taskId: params.taskId, + fieldId: params.fieldId, + } + case 'create_checklist': + return { + ...baseParams, + taskId: params.taskId, + name: params.name, + } + case 'update_checklist': + return { + ...baseParams, + checklistId: params.checklistId, + name: params.name || undefined, + position: optionalNumber(params.position), + } + case 'delete_checklist': + return { + ...baseParams, + checklistId: params.checklistId, + } + case 'create_checklist_item': + return { + ...baseParams, + checklistId: params.checklistId, + name: params.name, + assignee: optionalNumber(params.itemAssignee), + } + case 'update_checklist_item': + return { + ...baseParams, + checklistId: params.checklistId, + checklistItemId: params.checklistItemId, + name: params.name || undefined, + assignee: optionalNumber(params.itemAssignee), + resolved: + params.resolvedAction === 'resolve' + ? true + : params.resolvedAction === 'unresolve' + ? false + : undefined, + parent: params.itemParent || undefined, + } + case 'delete_checklist_item': + return { + ...baseParams, + checklistId: params.checklistId, + checklistItemId: params.checklistItemId, + } + case 'get_time_entries': + return { + ...baseParams, + workspaceId: params.workspaceId, + startDate: optionalNumber(params.timeStartDate), + endDate: optionalNumber(params.timeEndDate), + assignee: params.timerAssignee || undefined, + taskId: + params.timeLocationType === 'task' ? params.timeLocationId || undefined : undefined, + listId: + params.timeLocationType === 'list' ? params.timeLocationId || undefined : undefined, + folderId: + params.timeLocationType === 'folder' + ? params.timeLocationId || undefined + : undefined, + spaceId: + params.timeLocationType === 'space' + ? params.timeLocationId || undefined + : undefined, + includeTaskTags: params.includeTaskTags ? true : undefined, + includeLocationNames: params.includeLocationNames ? true : undefined, + } + case 'create_time_entry': + return { + ...baseParams, + workspaceId: params.workspaceId, + start: optionalNumber(params.entryStart), + duration: optionalNumber(params.entryDuration), + description: params.entryDescription || undefined, + billable: billableFromAction(params.billableAction), + taskId: params.timerTaskId || undefined, + assignee: optionalNumber(params.entryAssignee), + } + case 'update_time_entry': + return { + ...baseParams, + workspaceId: params.workspaceId, + timerId: params.timerId, + description: params.entryDescription || undefined, + start: optionalNumber(params.entryStart), + end: optionalNumber(params.entryEnd), + duration: optionalNumber(params.entryDuration), + taskId: params.timerTaskId || undefined, + billable: billableFromAction(params.billableAction), + } + case 'delete_time_entry': + return { + ...baseParams, + workspaceId: params.workspaceId, + timerId: params.timerId, + } + case 'start_timer': + return { + ...baseParams, + workspaceId: params.workspaceId, + taskId: params.timerTaskId || undefined, + description: params.entryDescription || undefined, + billable: billableFromAction(params.billableAction), + tags: splitCommaSeparated(params.timerTags), + } + case 'stop_timer': + return { + ...baseParams, + workspaceId: params.workspaceId, + } + case 'get_running_timer': + return { + ...baseParams, + workspaceId: params.workspaceId, + assignee: optionalNumber(params.entryAssignee), + } + default: + return baseParams + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + oauthCredential: { type: 'string', description: 'ClickUp OAuth credential' }, + workspaceId: { type: 'string', description: 'Workspace (team) ID' }, + spaceId: { type: 'string', description: 'Space ID' }, + listParent: { + type: 'string', + description: 'Where lists live for list operations (folder or space)', + }, + folderId: { type: 'string', description: 'Folder ID' }, + listSpaceId: { type: 'string', description: 'Space ID for folderless list operations' }, + listId: { type: 'string', description: 'List ID' }, + taskId: { type: 'string', description: 'Task ID' }, + commentId: { type: 'string', description: 'Comment ID' }, + name: { type: 'string', description: 'Name for the task, folder, or list' }, + description: { type: 'string', description: 'Task description' }, + markdownContent: { type: 'string', description: 'Markdown description' }, + status: { type: 'string', description: 'Task status' }, + priority: { type: 'string', description: 'Task priority (1-4)' }, + dueDate: { type: 'string', description: 'Due date (Unix ms)' }, + dueDateTime: { type: 'boolean', description: 'Whether the due date includes a time of day' }, + startDate: { type: 'string', description: 'Start date (Unix ms)' }, + startDateTime: { + type: 'boolean', + description: 'Whether the start date includes a time of day', + }, + assignees: { type: 'string', description: 'Comma-separated assignee user IDs' }, + assigneesToAdd: { type: 'string', description: 'Comma-separated user IDs to add as assignees' }, + assigneesToRemove: { + type: 'string', + description: 'Comma-separated user IDs to remove from assignees', + }, + tags: { type: 'string', description: 'Comma-separated tag names' }, + timeEstimate: { type: 'string', description: 'Time estimate in milliseconds' }, + points: { type: 'string', description: 'Sprint points' }, + parent: { type: 'string', description: 'Parent task ID' }, + notifyAll: { type: 'boolean', description: 'Notify the creator' }, + archiveAction: { type: 'string', description: 'Archive action (none, archive, unarchive)' }, + includeSubtasks: { type: 'boolean', description: 'Include subtasks' }, + includeMarkdownDescription: { + type: 'boolean', + description: 'Return the description in Markdown', + }, + page: { type: 'string', description: 'Page to fetch (starts at 0)' }, + orderBy: { type: 'string', description: 'Order-by field' }, + reverse: { type: 'boolean', description: 'Reverse order' }, + subtasks: { type: 'boolean', description: 'Include subtasks in results' }, + includeClosed: { type: 'boolean', description: 'Include closed tasks' }, + archived: { type: 'boolean', description: 'Return archived items' }, + statuses: { type: 'string', description: 'Comma-separated status filters' }, + dueDateGt: { type: 'string', description: 'Only tasks due after this Unix ms timestamp' }, + dueDateLt: { type: 'string', description: 'Only tasks due before this Unix ms timestamp' }, + listIds: { type: 'string', description: 'Comma-separated list ID filters' }, + spaceIds: { type: 'string', description: 'Comma-separated space ID filters' }, + folderIds: { type: 'string', description: 'Comma-separated folder ID filters' }, + commentText: { type: 'string', description: 'Comment text' }, + commentAssignee: { type: 'string', description: 'User ID to assign the comment to' }, + resolvedAction: { type: 'string', description: 'Resolved action (none, resolve, unresolve)' }, + start: { type: 'string', description: 'Pagination start timestamp (Unix ms)' }, + startId: { type: 'string', description: 'Pagination start comment ID' }, + tagName: { type: 'string', description: 'Tag name' }, + content: { type: 'string', description: 'List description' }, + file: { type: 'json', description: 'File to upload as an attachment' }, + fieldId: { type: 'string', description: 'Custom field UUID' }, + fieldValue: { + type: 'string', + description: 'Custom field value (plain value, or JSON for structured types)', + }, + checklistId: { type: 'string', description: 'Checklist UUID' }, + checklistItemId: { type: 'string', description: 'Checklist item UUID' }, + position: { type: 'string', description: 'New checklist position on the task' }, + itemAssignee: { type: 'string', description: 'User ID to assign the checklist item to' }, + itemParent: { type: 'string', description: 'Checklist item UUID to nest under' }, + timerId: { type: 'string', description: 'Time entry ID' }, + entryStart: { type: 'string', description: 'Time entry start (Unix ms)' }, + entryEnd: { type: 'string', description: 'Time entry end (Unix ms)' }, + entryDuration: { type: 'string', description: 'Time entry duration in milliseconds' }, + entryDescription: { type: 'string', description: 'Time entry description' }, + billableAction: { + type: 'string', + description: 'Billable action (none, billable, non_billable)', + }, + timerTaskId: { type: 'string', description: 'Task ID to associate the time entry with' }, + timerTags: { type: 'string', description: 'Comma-separated time entry tag names' }, + timerAssignee: { + type: 'string', + description: 'Comma-separated user IDs to filter time entries by', + }, + entryAssignee: { type: 'string', description: 'Single user ID for the time entry' }, + timeStartDate: { type: 'string', description: 'Time entry range start (Unix ms)' }, + timeEndDate: { type: 'string', description: 'Time entry range end (Unix ms)' }, + timeLocationType: { + type: 'string', + description: 'Time entry location filter type (none, task, list, folder, space)', + }, + timeLocationId: { type: 'string', description: 'ID for the time entry location filter' }, + includeTaskTags: { type: 'boolean', description: 'Include task tags in time entries' }, + includeLocationNames: { + type: 'boolean', + description: 'Include list, folder, and space names in time entries', + }, + }, + outputs: { + task: { type: 'json', description: 'Task details' }, + tasks: { type: 'json', description: 'Array of tasks' }, + comments: { type: 'json', description: 'Array of comments' }, + id: { type: 'string', description: 'ID of the affected resource' }, + histId: { type: 'string', description: 'History ID of the created comment' }, + date: { type: 'number', description: 'Timestamp of the created comment (Unix ms)' }, + updated: { type: 'boolean', description: 'Whether the resource was updated' }, + deleted: { type: 'boolean', description: 'Whether the resource was deleted' }, + attachment: { type: 'json', description: 'Uploaded attachment details' }, + files: { type: 'json', description: 'Uploaded attachment files' }, + taskId: { type: 'string', description: 'Task ID for tag and custom field operations' }, + fieldId: { type: 'string', description: 'Custom field ID for custom field operations' }, + tagName: { type: 'string', description: 'Tag name for tag operations' }, + tags: { type: 'json', description: 'Array of space tags' }, + members: { type: 'json', description: 'Array of members' }, + fields: { type: 'json', description: 'Array of custom fields' }, + workspaces: { type: 'json', description: 'Array of workspaces' }, + spaces: { type: 'json', description: 'Array of spaces' }, + folders: { type: 'json', description: 'Array of folders' }, + folder: { type: 'json', description: 'Created folder details' }, + lists: { type: 'json', description: 'Array of lists' }, + list: { type: 'json', description: 'Created list details' }, + checklist: { type: 'json', description: 'Checklist details including its items' }, + timeEntry: { type: 'json', description: 'Time entry details' }, + timeEntries: { type: 'json', description: 'Array of time entries' }, + }, +} + +export const ClickUpBlockMeta = { + tags: ['project-management', 'ticketing', 'automation'], + url: 'https://clickup.com', + templates: [ + { + icon: ClickUpIcon, + title: 'ClickUp task intake', + prompt: + 'Build a workflow that turns incoming Slack requests into ClickUp tasks in the right list with a clear name, description, priority, and due date, then replies in the thread with the task link.', + modules: ['agent', 'workflows'], + category: 'productivity', + tags: ['team', 'automation'], + alsoIntegrations: ['slack'], + }, + { + icon: ClickUpIcon, + title: 'ClickUp standup digest', + prompt: + 'Create a scheduled weekday workflow that pulls open ClickUp tasks for each list, groups them by assignee and status, and posts a morning standup summary to the team Slack channel.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'productivity', + tags: ['team', 'reporting'], + alsoIntegrations: ['slack'], + }, + { + icon: ClickUpIcon, + title: 'ClickUp overdue-task nudger', + prompt: + 'Build a scheduled workflow that searches ClickUp for tasks past their due date, adds a comment asking for a status update, and emails each assignee a digest of their overdue work.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'productivity', + tags: ['team', 'monitoring'], + alsoIntegrations: ['gmail'], + }, + { + icon: ClickUpIcon, + title: 'ClickUp bug triager', + prompt: + 'Create a workflow that reads newly reported bugs from a ClickUp list, classifies each by severity and component with an agent, sets the priority and tags accordingly, and opens a matching GitHub issue for engineering.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['engineering', 'automation'], + alsoIntegrations: ['github'], + }, + { + icon: ClickUpIcon, + title: 'ClickUp sprint retro writer', + prompt: + 'Build a workflow that pulls the ClickUp tasks completed during a sprint, summarizes wins, blockers, and recurring themes with an agent, and shares a retro document with the team in Slack.', + modules: ['agent', 'files', 'workflows'], + category: 'productivity', + tags: ['team', 'reporting'], + alsoIntegrations: ['slack'], + }, + { + icon: ClickUpIcon, + title: 'ClickUp customer onboarding launcher', + prompt: + 'Create a workflow that on a closed-won Salesforce opportunity creates a ClickUp onboarding task with the right assignees and due date, attaches the signed order form, and writes the task link back to the opportunity.', + modules: ['agent', 'workflows'], + category: 'sales', + tags: ['sales', 'crm'], + alsoIntegrations: ['salesforce'], + }, + { + icon: ClickUpIcon, + title: 'ClickUp meeting action items', + prompt: + 'Build a workflow that takes meeting notes, extracts action items with an agent, creates a ClickUp task for each with the right assignee and due date, and replies with a checklist of created tasks.', + modules: ['agent', 'workflows'], + category: 'productivity', + tags: ['team', 'automation'], + }, + ], + skills: [ + { + name: 'create-task-from-request', + description: + 'Turn an incoming request or message into a well-formed ClickUp task in the right list with priority, assignees, and due date. Use for intake and ticket creation.', + content: + '# Create Task from Request\n\nConvert an incoming request into a structured ClickUp task.\n\n## Steps\n1. Extract the work to be done, the target list, any assignees, a priority, and a due date.\n2. If the list is referenced by name, walk the hierarchy (workspaces, spaces, folders, lists) to resolve its ID.\n3. Create the task with a clear name, a description capturing the request details, and the extracted fields.\n4. Add a comment with any links or source context if helpful.\n\n## Output\nReport the created task name, its URL or ID, list, assignees, and due date.', + }, + { + name: 'summarize-list-tasks', + description: + 'Fetch the tasks in a ClickUp list and summarize status, overdue items, and who owns what. Use for standups and project status checks.', + content: + '# Summarize List Tasks\n\nProduce a status snapshot of a ClickUp list.\n\n## Steps\n1. Resolve the list, then fetch its tasks (include closed tasks when a full picture is needed).\n2. For each task capture name, assignees, status, priority, and due date.\n3. Group into completed, in progress, and overdue or due soon.\n4. Note any unassigned tasks or tasks with no due date.\n\n## Output\nA concise status summary: counts per group, overdue tasks called out by name and owner, and any gaps to address.', + }, + { + name: 'update-task-status', + description: + 'Find a ClickUp task and update its fields — status, priority, due date — or add a progress comment. Use to keep tasks current from other systems.', + content: + '# Update Task Status\n\nKeep a ClickUp task in sync with the latest state.\n\n## Steps\n1. Identify the target task by ID, or search the workspace to find it.\n2. Read the current task to confirm it is the right one.\n3. Update the relevant fields — status, priority, due date, or archive state.\n4. Add a comment summarizing what changed and why.\n\n## Output\nReport which fields changed and confirm the task ID. If no matching task was found, say so.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index bf6fe014b37..109bc55b652 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -32,6 +32,7 @@ import { CirclebackBlock, CirclebackBlockMeta } from '@/blocks/blocks/circleback import { ClayBlock, ClayBlockMeta } from '@/blocks/blocks/clay' import { ClerkBlock, ClerkBlockMeta } from '@/blocks/blocks/clerk' import { ClickHouseBlock, ClickHouseBlockMeta } from '@/blocks/blocks/clickhouse' +import { ClickUpBlock, ClickUpBlockMeta } from '@/blocks/blocks/clickup' import { CloudflareBlock, CloudflareBlockMeta } from '@/blocks/blocks/cloudflare' import { CloudFormationBlock, CloudFormationBlockMeta } from '@/blocks/blocks/cloudformation' import { CloudWatchBlock, CloudWatchBlockMeta } from '@/blocks/blocks/cloudwatch' @@ -373,6 +374,7 @@ export const BLOCK_REGISTRY: Record = { clay: ClayBlock, clerk: ClerkBlock, clickhouse: ClickHouseBlock, + clickup: ClickUpBlock, cloudflare: CloudflareBlock, cloudformation: CloudFormationBlock, cloudwatch: CloudWatchBlock, @@ -694,6 +696,7 @@ export const BLOCK_META_REGISTRY: Record = { clay: ClayBlockMeta, clerk: ClerkBlockMeta, clickhouse: ClickHouseBlockMeta, + clickup: ClickUpBlockMeta, cloudflare: CloudflareBlockMeta, cloudformation: CloudFormationBlockMeta, cloudwatch: CloudWatchBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index e7acb6322a2..bae77686e5f 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2801,6 +2801,52 @@ export function LinqIcon(props: SVGProps) { ) } +export function ClickUpIcon(props: SVGProps) { + const id = useId() + const bodyGradientId = `clickup_body_${id}` + const arrowGradientId = `clickup_arrow_${id}` + return ( + + + + + + + + + + + + + ) +} + export function LinearIcon(props: React.SVGProps) { return ( +export type ClickUpUploadAttachmentApiResponse = ContractJsonResponse< + typeof clickupUploadAttachmentContract +> diff --git a/apps/sim/lib/api/contracts/tools/index.ts b/apps/sim/lib/api/contracts/tools/index.ts index 3270a34ef99..c1bfb0ccdbe 100644 --- a/apps/sim/lib/api/contracts/tools/index.ts +++ b/apps/sim/lib/api/contracts/tools/index.ts @@ -2,6 +2,7 @@ export * from './a2a' export * from './agiloft' export * from './asana' export * from './brex' +export * from './clickup' export * from './communication' export * from './crowdstrike' export * from './cursor' diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 61a104eccf2..a4aeafc2343 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -2348,6 +2348,55 @@ export const auth = betterAuth({ }, }, + { + providerId: 'clickup', + clientId: env.CLICKUP_CLIENT_ID as string, + clientSecret: env.CLICKUP_CLIENT_SECRET as string, + authorizationUrl: 'https://app.clickup.com/api', + tokenUrl: 'https://api.clickup.com/api/v2/oauth/token', + scopes: getCanonicalScopesForProvider('clickup'), + responseType: 'code', + pkce: false, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/clickup`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.clickup.com/api/v2/user', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching ClickUp user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const user = data.user + if (!user?.id) return null + + const now = new Date() + return { + id: `${user.id.toString()}-${generateId()}`, + name: user.username || 'ClickUp User', + email: user.email || `${user.id}@clickup.user`, + emailVerified: !!user.email, + createdAt: now, + updatedAt: now, + image: user.profilePicture || undefined, + } + } catch (error) { + logger.error('Error in ClickUp getUserInfo:', { error }) + return null + } + }, + }, + { providerId: 'linear', clientId: env.LINEAR_CLIENT_ID as string, diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index cbb95efd5fb..d3c6f4ba61c 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -391,6 +391,8 @@ export const env = createEnv({ PIPEDRIVE_CLIENT_SECRET: z.string().optional(), // Pipedrive OAuth client secret LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID LINEAR_CLIENT_SECRET: z.string().optional(), // Linear OAuth client secret + CLICKUP_CLIENT_ID: z.string().optional(), // ClickUp OAuth client ID + CLICKUP_CLIENT_SECRET: z.string().optional(), // ClickUp OAuth client secret BOX_CLIENT_ID: z.string().optional(), // Box OAuth client ID BOX_CLIENT_SECRET: z.string().optional(), // Box OAuth client secret DROPBOX_CLIENT_ID: z.string().optional(), // Dropbox OAuth client ID diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index eac00def403..f3661a6ff93 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -53,6 +53,7 @@ export const AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID = 'airtable-service-account' a export const NOTION_SERVICE_ACCOUNT_PROVIDER_ID = 'notion-service-account' as const export const ASANA_SERVICE_ACCOUNT_PROVIDER_ID = 'asana-service-account' as const export const ATTIO_SERVICE_ACCOUNT_PROVIDER_ID = 'attio-service-account' as const +export const CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID = 'clickup-service-account' as const export const LINEAR_SERVICE_ACCOUNT_PROVIDER_ID = 'linear-service-account' as const export const MONDAY_SERVICE_ACCOUNT_PROVIDER_ID = 'monday-service-account' as const export const SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID = 'shopify-service-account' as const @@ -69,6 +70,7 @@ export type TokenServiceAccountProviderId = | typeof NOTION_SERVICE_ACCOUNT_PROVIDER_ID | typeof ASANA_SERVICE_ACCOUNT_PROVIDER_ID | typeof ATTIO_SERVICE_ACCOUNT_PROVIDER_ID + | typeof CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID | typeof LINEAR_SERVICE_ACCOUNT_PROVIDER_ID | typeof MONDAY_SERVICE_ACCOUNT_PROVIDER_ID | typeof SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID @@ -172,6 +174,23 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< helpText: 'Check the scopes granted to the key in Attio — tools whose scopes are missing will fail at run time.', }, + [CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'ClickUp', + tokenNoun: 'personal API token', + connectNoun: 'API token', + fields: [ + { + id: 'apiToken', + label: 'API token', + placeholder: 'pk_...', + secret: true, + hintPattern: /^pk_/, + hintMessage: 'ClickUp personal API tokens start with pk_.', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/clickup-service-account', + }, [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: LINEAR_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Linear', diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index c0a2ed16d1d..0d232c414cc 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -3,6 +3,7 @@ import { ASANA_SERVICE_ACCOUNT_PROVIDER_ID, ATTIO_SERVICE_ACCOUNT_PROVIDER_ID, CALCOM_SERVICE_ACCOUNT_PROVIDER_ID, + CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID, HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID, isTokenServiceAccountProviderId, LINEAR_SERVICE_ACCOUNT_PROVIDER_ID, @@ -19,6 +20,7 @@ import { validateAirtableServiceAccount } from '@/lib/credentials/token-service- import { validateAsanaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/asana' import { validateAttioServiceAccount } from '@/lib/credentials/token-service-accounts/validators/attio' import { validateCalcomServiceAccount } from '@/lib/credentials/token-service-accounts/validators/calcom' +import { validateClickupServiceAccount } from '@/lib/credentials/token-service-accounts/validators/clickup' import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot' import { validateLinearServiceAccount } from '@/lib/credentials/token-service-accounts/validators/linear' import { validateMondayServiceAccount } from '@/lib/credentials/token-service-accounts/validators/monday' @@ -67,6 +69,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [NOTION_SERVICE_ACCOUNT_PROVIDER_ID]: validateNotionServiceAccount, [ASANA_SERVICE_ACCOUNT_PROVIDER_ID]: validateAsanaServiceAccount, [ATTIO_SERVICE_ACCOUNT_PROVIDER_ID]: validateAttioServiceAccount, + [CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID]: validateClickupServiceAccount, [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: validateLinearServiceAccount, [MONDAY_SERVICE_ACCOUNT_PROVIDER_ID]: validateMondayServiceAccount, [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: validateShopifyServiceAccount, diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts new file mode 100644 index 00000000000..129fbb1fb02 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts @@ -0,0 +1,73 @@ +import { + fetchProvider, + parseProviderJson, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' +import { clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const CLICKUP_USER_URL = 'https://api.clickup.com/api/v2/user' + +interface ClickUpUserResponse { + user?: { + id?: number + username?: string | null + email?: string | null + } +} + +/** + * Validates a ClickUp personal API token by fetching the authorized user. The + * Authorization header is built with the same helper the runtime tools use + * (`clickupAuthorizationHeader`): personal `pk_` tokens go bare, anything else + * gets the `Bearer` prefix — so validation exercises the exact header shape + * tools will send. + */ +export async function validateClickupServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const res = await fetchProvider( + CLICKUP_USER_URL, + { + method: 'GET', + headers: { + Authorization: clickupAuthorizationHeader(fields.apiToken), + 'Content-Type': 'application/json', + }, + }, + 'user' + ) + + if (!res.ok) { + const body = await readProviderErrorSnippet(res) + if (res.status === 401 || res.status === 403) { + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step: 'user', + body, + }) + } + throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { + step: 'user', + body, + }) + } + + const payload = await parseProviderJson(res, 'user') + const user = payload.user + if (!user?.id) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'user', + reason: 'missing user in response', + }) + } + + return { + displayName: user.username || user.email || 'ClickUp account', + auditMetadata: { clickupUserId: String(user.id) }, + storedMetadata: { userId: String(user.id) }, + } +} diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 621aea2f729..185e321db5c 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -34,6 +34,7 @@ import { ClayIcon, ClerkIcon, ClickHouseIcon, + ClickUpIcon, CloudFormationIcon, CloudflareIcon, CloudWatchIcon, @@ -271,6 +272,7 @@ export const blockTypeToIconMap: Record = { clay: ClayIcon, clerk: ClerkIcon, clickhouse: ClickHouseIcon, + clickup: ClickUpIcon, cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 6d2d8a661c9..c8b0ad7fe1e 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -3239,6 +3239,178 @@ "integrationType": "databases", "tags": ["data-warehouse", "data-analytics"] }, + { + "type": "clickup", + "slug": "clickup", + "name": "ClickUp", + "description": "Interact with ClickUp", + "longDescription": "Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields.", + "bgColor": "#FFFFFF", + "iconName": "ClickUpIcon", + "docsUrl": "https://docs.sim.ai/integrations/clickup", + "operations": [ + { + "name": "Create Task", + "description": "Create a new task in a ClickUp list" + }, + { + "name": "Get Task", + "description": "Retrieve a task from ClickUp by ID" + }, + { + "name": "Update Task", + "description": "Update an existing task in ClickUp" + }, + { + "name": "Delete Task", + "description": "Delete a task from ClickUp" + }, + { + "name": "Get Tasks", + "description": "List the tasks in a ClickUp list (100 tasks per page; increment page until an empty result to paginate)" + }, + { + "name": "Search Tasks", + "description": "Search tasks across a ClickUp workspace with filters for lists, folders, spaces, statuses, assignees, tags, and due dates (100 tasks per page; increment page until an empty result to paginate)" + }, + { + "name": "Create Comment", + "description": "Add a comment to a ClickUp task" + }, + { + "name": "Get Comments", + "description": "Retrieve comments on a ClickUp task, newest first (25 per page; paginate with start and startId)" + }, + { + "name": "Update Comment", + "description": "Update the content, assignee, or resolved state of a ClickUp task comment" + }, + { + "name": "Delete Comment", + "description": "Delete a comment from a ClickUp task" + }, + { + "name": "Upload Attachment", + "description": "Upload a file to a ClickUp task as an attachment" + }, + { + "name": "Add Tag to Task", + "description": "Add an existing space tag to a ClickUp task" + }, + { + "name": "Remove Tag from Task", + "description": "Remove a tag from a ClickUp task" + }, + { + "name": "Get Space Tags", + "description": "List the task tags available in a ClickUp space" + }, + { + "name": "Get Task Members", + "description": "List the workspace members who have explicit access to a ClickUp task" + }, + { + "name": "Get List Members", + "description": "List the workspace members who have explicit access to a ClickUp list" + }, + { + "name": "Get Custom Fields", + "description": "List the custom fields accessible in a ClickUp list" + }, + { + "name": "Set Custom Field Value", + "description": "Set the value of a custom field on a ClickUp task (the value shape depends on the field type)" + }, + { + "name": "Remove Custom Field Value", + "description": "Remove the value of a custom field from a ClickUp task (does not delete the field itself)" + }, + { + "name": "Create Checklist", + "description": "Add a new checklist to a ClickUp task" + }, + { + "name": "Update Checklist", + "description": "Rename or reorder a checklist on a ClickUp task" + }, + { + "name": "Delete Checklist", + "description": "Delete a checklist from a ClickUp task" + }, + { + "name": "Create Checklist Item", + "description": "Add an item to a checklist on a ClickUp task" + }, + { + "name": "Update Checklist Item", + "description": "Update a checklist item on a ClickUp task \u2014 rename, assign, resolve, or nest it" + }, + { + "name": "Delete Checklist Item", + "description": "Delete an item from a checklist on a ClickUp task" + }, + { + "name": "Get Time Entries", + "description": "List time entries in a ClickUp workspace within a date range (defaults to the last 30 days for the authenticated user)" + }, + { + "name": "Create Time Entry", + "description": "Create a manual time entry in a ClickUp workspace, optionally linked to a task" + }, + { + "name": "Update Time Entry", + "description": "Update a time entry in a ClickUp workspace \u2014 description, start/end times, task, or billable state" + }, + { + "name": "Delete Time Entry", + "description": "Delete a time entry from a ClickUp workspace" + }, + { + "name": "Start Timer", + "description": "Start a timer for the authenticated user in a ClickUp workspace" + }, + { + "name": "Stop Timer", + "description": "Stop the authenticated user's currently running timer in a ClickUp workspace" + }, + { + "name": "Get Running Timer", + "description": "Get the currently running time entry in a ClickUp workspace (null when no timer is running)" + }, + { + "name": "Get Workspaces", + "description": "List the ClickUp workspaces (teams) available to the connected account" + }, + { + "name": "Get Spaces", + "description": "List the spaces in a ClickUp workspace" + }, + { + "name": "Get Folders", + "description": "List the folders in a ClickUp space" + }, + { + "name": "Get Lists", + "description": "List the lists in a ClickUp folder, or the folderless lists in a space when a space ID is provided instead" + }, + { + "name": "Create Folder", + "description": "Create a new folder in a ClickUp space" + }, + { + "name": "Create List", + "description": "Create a new list in a ClickUp folder, or a folderless list in a space when a space ID is provided instead" + } + ], + "operationCount": 38, + "triggers": [], + "triggerCount": 0, + "authType": "oauth", + "oauthServiceId": "clickup", + "category": "tools", + "integrationType": "productivity", + "tags": ["project-management", "ticketing", "automation"] + }, { "type": "cloudflare", "slug": "cloudflare", diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index d1a0bb00dbe..33d810f1f48 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -8,6 +8,7 @@ import { AzureIcon, BoxCompanyIcon, CalComIcon, + ClickUpIcon, ConfluenceIcon, DocuSignIcon, DropboxIcon, @@ -626,6 +627,22 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'notion', }, + clickup: { + name: 'ClickUp', + icon: ClickUpIcon, + services: { + clickup: { + name: 'ClickUp', + description: 'Manage tasks, lists, and comments in ClickUp.', + providerId: 'clickup', + serviceAccountProviderId: 'clickup-service-account', + icon: ClickUpIcon, + baseProviderIcon: ClickUpIcon, + scopes: [], + }, + }, + defaultService: 'clickup', + }, linear: { name: 'Linear', icon: LinearIcon, @@ -1270,6 +1287,19 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: true, } } + case 'clickup': { + const { clientId, clientSecret } = getCredentials( + env.CLICKUP_CLIENT_ID, + env.CLICKUP_CLIENT_SECRET + ) + return { + tokenEndpoint: 'https://api.clickup.com/api/v2/oauth/token', + clientId, + clientSecret, + useBasicAuth: false, + supportsRefreshTokenRotation: false, + } + } case 'linear': { const { clientId, clientSecret } = getCredentials( env.LINEAR_CLIENT_ID, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 96b7f1a06eb..40c24fe8547 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -65,6 +65,7 @@ export type OAuthProvider = | 'outlook' | 'onedrive' | 'sharepoint' + | 'clickup' | 'linear' | 'slack' | 'reddit' @@ -117,6 +118,7 @@ export type OAuthService = | 'microsoft-planner' | 'sharepoint' | 'outlook' + | 'clickup' | 'linear' | 'slack' | 'reddit' diff --git a/apps/sim/tools/clickup/add_tag_to_task.ts b/apps/sim/tools/clickup/add_tag_to_task.ts new file mode 100644 index 00000000000..47d9abed295 --- /dev/null +++ b/apps/sim/tools/clickup/add_tag_to_task.ts @@ -0,0 +1,68 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpTaskTagParams, ClickUpTaskTagResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupAddTagToTaskTool: ToolConfig = { + id: 'clickup_add_tag_to_task', + name: 'ClickUp Add Tag To Task', + description: 'Add an existing space tag to a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to tag', + }, + tagName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the tag to add (must exist in the space)', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/tag/${encodeURIComponent(params.tagName)}`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to add tag to task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { taskId: params?.taskId, tagName: params?.tagName }, + } + }, + + outputs: { + taskId: { type: 'string', description: 'ID of the tagged task', optional: true }, + tagName: { type: 'string', description: 'Name of the tag that was added', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/create_checklist.ts b/apps/sim/tools/clickup/create_checklist.ts new file mode 100644 index 00000000000..08983d20615 --- /dev/null +++ b/apps/sim/tools/clickup/create_checklist.ts @@ -0,0 +1,79 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpChecklist, +} from '@/tools/clickup/shared' +import type { ClickUpChecklistResponse, ClickUpCreateChecklistParams } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateChecklistTool: ToolConfig< + ClickUpCreateChecklistParams, + ClickUpChecklistResponse +> = { + id: 'clickup_create_checklist', + name: 'ClickUp Create Checklist', + description: 'Add a new checklist to a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to add the checklist to', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the checklist', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/checklist`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => ({ name: params.name }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create checklist') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { checklist: mapClickUpChecklist(isRecordLike(data) ? data.checklist : null) }, + } + }, + + outputs: { + checklist: { + type: 'json', + description: 'The created checklist', + optional: true, + properties: CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/create_checklist_item.ts b/apps/sim/tools/clickup/create_checklist_item.ts new file mode 100644 index 00000000000..8476923dc9e --- /dev/null +++ b/apps/sim/tools/clickup/create_checklist_item.ts @@ -0,0 +1,97 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpChecklist, +} from '@/tools/clickup/shared' +import type { + ClickUpChecklistResponse, + ClickUpCreateChecklistItemParams, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateChecklistItemTool: ToolConfig< + ClickUpCreateChecklistItemParams, + ClickUpChecklistResponse +> = { + id: 'clickup_create_checklist_item', + name: 'ClickUp Create Checklist Item', + description: 'Add an item to a checklist on a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist to add the item to', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the checklist item', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to assign the item to', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/checklist/${encodeURIComponent(params.checklistId)}/checklist_item`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + name: params.name, + } + + if (params.assignee !== undefined) body.assignee = params.assignee + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create checklist item') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { checklist: mapClickUpChecklist(isRecordLike(data) ? data.checklist : null) }, + } + }, + + outputs: { + checklist: { + type: 'json', + description: 'The updated checklist including its items', + optional: true, + properties: CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/create_comment.ts b/apps/sim/tools/clickup/create_comment.ts new file mode 100644 index 00000000000..ffb8742e9c7 --- /dev/null +++ b/apps/sim/tools/clickup/create_comment.ts @@ -0,0 +1,118 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpCreateCommentParams, + ClickUpCreateCommentResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateCommentTool: ToolConfig< + ClickUpCreateCommentParams, + ClickUpCreateCommentResponse +> = { + id: 'clickup_create_comment', + name: 'ClickUp Create Comment', + description: 'Add a comment to a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to comment on', + }, + commentText: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Content of the comment', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to assign the comment to', + }, + notifyAll: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'When true, comment notifications are sent to everyone, including the comment creator', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/comment`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + comment_text: params.commentText, + notify_all: params.notifyAll ?? false, + } + + if (params.assignee !== undefined) body.assignee = params.assignee + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create comment') + return { success: false, output: { error }, error } + } + + const record = isRecordLike(data) ? data : {} + const id = record.id + const histId = record.hist_id + const rawDate = record.date + const date = + typeof rawDate === 'number' + ? rawDate + : typeof rawDate === 'string' && Number.isFinite(Number(rawDate)) + ? Number(rawDate) + : undefined + + return { + success: true, + output: { + id: id === undefined || id === null ? undefined : String(id), + histId: histId === undefined || histId === null ? undefined : String(histId), + date, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the created comment', optional: true }, + histId: { type: 'string', description: 'History ID of the created comment', optional: true }, + date: { + type: 'number', + description: 'Creation timestamp of the comment (Unix ms)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/clickup/create_folder.ts b/apps/sim/tools/clickup/create_folder.ts new file mode 100644 index 00000000000..e8d6c53b474 --- /dev/null +++ b/apps/sim/tools/clickup/create_folder.ts @@ -0,0 +1,76 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_FOLDER_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpFolder, +} from '@/tools/clickup/shared' +import type { ClickUpCreateFolderParams, ClickUpFolderResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateFolderTool: ToolConfig = + { + id: 'clickup_create_folder', + name: 'ClickUp Create Folder', + description: 'Create a new folder in a ClickUp space', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + spaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space to create the folder in', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the folder', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(params.spaceId)}/folder`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => ({ name: params.name }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create folder') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { folder: mapClickUpFolder(data) }, + } + }, + + outputs: { + folder: { + type: 'json', + description: 'The created folder', + optional: true, + properties: CLICKUP_FOLDER_OUTPUT_PROPERTIES, + }, + }, + } diff --git a/apps/sim/tools/clickup/create_list.ts b/apps/sim/tools/clickup/create_list.ts new file mode 100644 index 00000000000..5dd011348a6 --- /dev/null +++ b/apps/sim/tools/clickup/create_list.ts @@ -0,0 +1,115 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_LIST_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpList, +} from '@/tools/clickup/shared' +import type { ClickUpCreateListParams, ClickUpListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateListTool: ToolConfig = { + id: 'clickup_create_list', + name: 'ClickUp Create List', + description: + 'Create a new list in a ClickUp folder, or a folderless list in a space when a space ID is provided instead', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + folderId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ID of the folder to create the list in (provide this or spaceId; folderId takes precedence when both are set)', + }, + spaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the space to create a folderless list in (provide this or folderId)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the list', + }, + content: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Plain text description of the list', + }, + markdownContent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Markdown description of the list (use instead of content)', + }, + }, + + request: { + url: (params) => { + if (params.folderId) { + return `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(params.folderId)}/list` + } + if (params.spaceId) { + return `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(params.spaceId)}/list` + } + throw new Error('Either a folder ID or a space ID is required to create a list') + }, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + name: params.name, + } + + if (params.markdownContent) { + body.markdown_content = params.markdownContent + } else if (params.content) { + body.content = params.content + } + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create list') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { list: mapClickUpList(data) }, + } + }, + + outputs: { + list: { + type: 'json', + description: 'The created list', + optional: true, + properties: CLICKUP_LIST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/create_task.ts b/apps/sim/tools/clickup/create_task.ts new file mode 100644 index 00000000000..9402e6f7de5 --- /dev/null +++ b/apps/sim/tools/clickup/create_task.ts @@ -0,0 +1,189 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TASK_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTask, +} from '@/tools/clickup/shared' +import type { ClickUpCreateTaskParams, ClickUpTaskResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateTaskTool: ToolConfig = { + id: 'clickup_create_task', + name: 'ClickUp Create Task', + description: 'Create a new task in a ClickUp list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the list to create the task in', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the task', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Plain text description of the task', + }, + markdownContent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Markdown description of the task (overrides description)', + }, + status: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Status to create the task with (must exist in the list)', + }, + priority: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Priority: 1 (urgent), 2 (high), 3 (normal), 4 (low)', + }, + dueDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Due date as a Unix timestamp in milliseconds', + }, + dueDateTime: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the due date includes a time of day', + }, + startDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Start date as a Unix timestamp in milliseconds', + }, + startDateTime: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the start date includes a time of day', + }, + assignees: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs to assign to the task', + items: { + type: 'number', + description: 'A ClickUp user ID', + }, + }, + tags: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Tag names to apply to the task', + items: { + type: 'string', + description: 'A tag name', + }, + }, + timeEstimate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Time estimate in milliseconds', + }, + points: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Sprint points for the task', + }, + parent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Parent task ID to create this task as a subtask', + }, + notifyAll: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'When true, creation notifications are sent to everyone, including the task creator', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/list/${encodeURIComponent(params.listId)}/task`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + name: params.name, + } + + if (params.description) body.description = params.description + if (params.markdownContent) body.markdown_content = params.markdownContent + if (params.status) body.status = params.status + if (params.priority !== undefined) body.priority = params.priority + if (params.dueDate !== undefined) body.due_date = params.dueDate + if (params.dueDateTime !== undefined) body.due_date_time = params.dueDateTime + if (params.startDate !== undefined) body.start_date = params.startDate + if (params.startDateTime !== undefined) body.start_date_time = params.startDateTime + if (params.assignees?.length) body.assignees = params.assignees + if (params.tags?.length) body.tags = params.tags + if (params.timeEstimate !== undefined) body.time_estimate = params.timeEstimate + if (params.points !== undefined) body.points = params.points + if (params.parent) body.parent = params.parent + if (params.notifyAll !== undefined) body.notify_all = params.notifyAll + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { task: mapClickUpTask(data) }, + } + }, + + outputs: { + task: { + type: 'json', + description: 'The created task', + optional: true, + properties: CLICKUP_TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/create_time_entry.ts b/apps/sim/tools/clickup/create_time_entry.ts new file mode 100644 index 00000000000..e31cb3de425 --- /dev/null +++ b/apps/sim/tools/clickup/create_time_entry.ts @@ -0,0 +1,129 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { ClickUpCreateTimeEntryParams, ClickUpTimeEntryResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateTimeEntryTool: ToolConfig< + ClickUpCreateTimeEntryParams, + ClickUpTimeEntryResponse +> = { + id: 'clickup_create_time_entry', + name: 'ClickUp Create Time Entry', + description: 'Create a manual time entry in a ClickUp workspace, optionally linked to a task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to create the entry in', + }, + start: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Start of the entry as a Unix timestamp in milliseconds', + }, + duration: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Duration of the entry in milliseconds', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the time entry', + }, + billable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the entry is billable', + }, + taskId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Task ID to associate the entry with', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to create the entry for (workspace owners/admins only)', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + start: params.start, + duration: params.duration, + } + + if (params.description) body.description = params.description + if (params.billable !== undefined) body.billable = params.billable + if (params.taskId) body.tid = params.taskId + if (params.assignee !== undefined) body.assignee = params.assignee + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create time entry') + return { success: false, output: { error }, error } + } + + const entry = + isRecordLike(data) && isRecordLike(data.data) + ? data.data + : isRecordLike(data) && data.id !== undefined + ? data + : null + + return { + success: true, + output: { timeEntry: entry ? mapClickUpTimeEntry(entry) : null }, + } + }, + + outputs: { + timeEntry: { + type: 'json', + description: 'The created time entry', + optional: true, + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/delete_checklist.ts b/apps/sim/tools/clickup/delete_checklist.ts new file mode 100644 index 00000000000..bac8bccdb6c --- /dev/null +++ b/apps/sim/tools/clickup/delete_checklist.ts @@ -0,0 +1,64 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpDeleteChecklistParams, ClickUpDeleteResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupDeleteChecklistTool: ToolConfig< + ClickUpDeleteChecklistParams, + ClickUpDeleteResponse +> = { + id: 'clickup_delete_checklist', + name: 'ClickUp Delete Checklist', + description: 'Delete a checklist from a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist to delete', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/checklist/${encodeURIComponent(params.checklistId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to delete checklist') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.checklistId, deleted: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the deleted checklist', optional: true }, + deleted: { type: 'boolean', description: 'Whether the checklist was deleted', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/delete_checklist_item.ts b/apps/sim/tools/clickup/delete_checklist_item.ts new file mode 100644 index 00000000000..0941a3371be --- /dev/null +++ b/apps/sim/tools/clickup/delete_checklist_item.ts @@ -0,0 +1,71 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpDeleteChecklistItemParams, ClickUpDeleteResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupDeleteChecklistItemTool: ToolConfig< + ClickUpDeleteChecklistItemParams, + ClickUpDeleteResponse +> = { + id: 'clickup_delete_checklist_item', + name: 'ClickUp Delete Checklist Item', + description: 'Delete an item from a checklist on a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist containing the item', + }, + checklistItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist item to delete', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/checklist/${encodeURIComponent(params.checklistId)}/checklist_item/${encodeURIComponent(params.checklistItemId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to delete checklist item') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.checklistItemId, deleted: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the deleted checklist item', optional: true }, + deleted: { type: 'boolean', description: 'Whether the item was deleted', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/delete_comment.ts b/apps/sim/tools/clickup/delete_comment.ts new file mode 100644 index 00000000000..2ad8005f55f --- /dev/null +++ b/apps/sim/tools/clickup/delete_comment.ts @@ -0,0 +1,64 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpDeleteCommentParams, ClickUpDeleteResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupDeleteCommentTool: ToolConfig< + ClickUpDeleteCommentParams, + ClickUpDeleteResponse +> = { + id: 'clickup_delete_comment', + name: 'ClickUp Delete Comment', + description: 'Delete a comment from a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the comment to delete', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/comment/${encodeURIComponent(params.commentId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to delete comment') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.commentId, deleted: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the deleted comment', optional: true }, + deleted: { type: 'boolean', description: 'Whether the comment was deleted', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/delete_task.ts b/apps/sim/tools/clickup/delete_task.ts new file mode 100644 index 00000000000..76f41ade6ef --- /dev/null +++ b/apps/sim/tools/clickup/delete_task.ts @@ -0,0 +1,61 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpDeleteResponse, ClickUpDeleteTaskParams } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupDeleteTaskTool: ToolConfig = { + id: 'clickup_delete_task', + name: 'ClickUp Delete Task', + description: 'Delete a task from ClickUp', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to delete', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to delete task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.taskId, deleted: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the deleted task', optional: true }, + deleted: { type: 'boolean', description: 'Whether the task was deleted', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/delete_time_entry.ts b/apps/sim/tools/clickup/delete_time_entry.ts new file mode 100644 index 00000000000..47c2f9927fd --- /dev/null +++ b/apps/sim/tools/clickup/delete_time_entry.ts @@ -0,0 +1,86 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { ClickUpDeleteTimeEntryParams, ClickUpTimeEntryResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupDeleteTimeEntryTool: ToolConfig< + ClickUpDeleteTimeEntryParams, + ClickUpTimeEntryResponse +> = { + id: 'clickup_delete_time_entry', + name: 'ClickUp Delete Time Entry', + description: 'Delete a time entry from a ClickUp workspace', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) the entry belongs to', + }, + timerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the time entry to delete', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries/${encodeURIComponent(params.timerId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to delete time entry') + return { success: false, output: { error }, error } + } + + const payload = isRecordLike(data) ? data.data : null + const entry = Array.isArray(payload) + ? (payload.find((item) => isRecordLike(item)) ?? null) + : isRecordLike(payload) + ? payload + : null + + return { + success: true, + output: { timeEntry: entry ? mapClickUpTimeEntry(entry) : null }, + } + }, + + outputs: { + timeEntry: { + type: 'json', + description: 'The deleted time entry', + optional: true, + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_comments.ts b/apps/sim/tools/clickup/get_comments.ts new file mode 100644 index 00000000000..bd1643e35c8 --- /dev/null +++ b/apps/sim/tools/clickup/get_comments.ts @@ -0,0 +1,98 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_COMMENT_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpComment, +} from '@/tools/clickup/shared' +import type { ClickUpCommentListResponse, ClickUpGetCommentsParams } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetCommentsTool: ToolConfig< + ClickUpGetCommentsParams, + ClickUpCommentListResponse +> = { + id: 'clickup_get_comments', + name: 'ClickUp Get Comments', + description: + 'Retrieve comments on a ClickUp task, newest first (25 per page; paginate with start and startId)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to fetch comments from', + }, + start: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Unix timestamp (ms) of the reference comment for pagination (use the date of the last comment from the previous page, together with startId)', + }, + startId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ID of the reference comment for pagination (use the id of the last comment from the previous page, together with start)', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/comment` + ) + if (params.start !== undefined) url.searchParams.set('start', String(params.start)) + if (params.startId) url.searchParams.set('start_id', params.startId) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get comments') + return { success: false, output: { error }, error } + } + + const rawComments = Array.isArray(data?.comments) ? data.comments : [] + + return { + success: true, + output: { comments: rawComments.map((comment: unknown) => mapClickUpComment(comment)) }, + } + }, + + outputs: { + comments: { + type: 'array', + description: 'Comments on the task, newest first', + optional: true, + items: { + type: 'object', + properties: CLICKUP_COMMENT_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_custom_fields.ts b/apps/sim/tools/clickup/get_custom_fields.ts new file mode 100644 index 00000000000..9f41bc48f10 --- /dev/null +++ b/apps/sim/tools/clickup/get_custom_fields.ts @@ -0,0 +1,101 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpCustomField, +} from '@/tools/clickup/shared' +import type { + ClickUpCustomFieldListResponse, + ClickUpGetCustomFieldsParams, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetCustomFieldsTool: ToolConfig< + ClickUpGetCustomFieldsParams, + ClickUpCustomFieldListResponse +> = { + id: 'clickup_get_custom_fields', + name: 'ClickUp Get Custom Fields', + description: 'List the custom fields accessible in a ClickUp list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the list to fetch custom fields from', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/list/${encodeURIComponent(params.listId)}/field`, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get custom fields') + return { success: false, output: { error }, error } + } + + const rawFields = Array.isArray(data?.fields) ? data.fields : [] + + return { + success: true, + output: { fields: rawFields.map((field: unknown) => mapClickUpCustomField(field)) }, + } + }, + + outputs: { + fields: { + type: 'array', + description: 'Custom fields accessible in the list', + optional: true, + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Custom field ID' }, + name: { type: 'string', description: 'Custom field name', nullable: true }, + type: { + type: 'string', + description: 'Custom field type (e.g. text, number, drop_down)', + nullable: true, + }, + typeConfig: { + type: 'json', + description: 'Type-specific configuration (e.g. dropdown options)', + nullable: true, + }, + dateCreated: { + type: 'string', + description: 'Creation timestamp (Unix ms)', + nullable: true, + }, + hideFromGuests: { + type: 'boolean', + description: 'Whether the field is hidden from guests', + nullable: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_folders.ts b/apps/sim/tools/clickup/get_folders.ts new file mode 100644 index 00000000000..bb100b96b54 --- /dev/null +++ b/apps/sim/tools/clickup/get_folders.ts @@ -0,0 +1,88 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_FOLDER_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpFolder, +} from '@/tools/clickup/shared' +import type { ClickUpFolderListResponse, ClickUpGetFoldersParams } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetFoldersTool: ToolConfig = + { + id: 'clickup_get_folders', + name: 'ClickUp Get Folders', + description: 'List the folders in a ClickUp space', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + spaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space to list folders from', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return archived folders', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(params.spaceId)}/folder` + ) + if (params.archived !== undefined) { + url.searchParams.set('archived', String(params.archived)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get folders') + return { success: false, output: { error }, error } + } + + const rawFolders = Array.isArray(data?.folders) ? data.folders : [] + + return { + success: true, + output: { folders: rawFolders.map((folder: unknown) => mapClickUpFolder(folder)) }, + } + }, + + outputs: { + folders: { + type: 'array', + description: 'Folders in the space', + optional: true, + items: { + type: 'object', + properties: CLICKUP_FOLDER_OUTPUT_PROPERTIES, + }, + }, + }, + } diff --git a/apps/sim/tools/clickup/get_list_members.ts b/apps/sim/tools/clickup/get_list_members.ts new file mode 100644 index 00000000000..22723efe5a1 --- /dev/null +++ b/apps/sim/tools/clickup/get_list_members.ts @@ -0,0 +1,76 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_MEMBER_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpMember, +} from '@/tools/clickup/shared' +import type { ClickUpGetListMembersParams, ClickUpMemberListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetListMembersTool: ToolConfig< + ClickUpGetListMembersParams, + ClickUpMemberListResponse +> = { + id: 'clickup_get_list_members', + name: 'ClickUp Get List Members', + description: 'List the workspace members who have explicit access to a ClickUp list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the list to list members for', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/list/${encodeURIComponent(params.listId)}/member`, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get list members') + return { success: false, output: { error }, error } + } + + const rawMembers = Array.isArray(data?.members) ? data.members : [] + + return { + success: true, + output: { members: rawMembers.map((member: unknown) => mapClickUpMember(member)) }, + } + }, + + outputs: { + members: { + type: 'array', + description: 'Members with explicit access to the list', + optional: true, + items: { + type: 'object', + properties: CLICKUP_MEMBER_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_lists.ts b/apps/sim/tools/clickup/get_lists.ts new file mode 100644 index 00000000000..f9c0852cb47 --- /dev/null +++ b/apps/sim/tools/clickup/get_lists.ts @@ -0,0 +1,101 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_LIST_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpList, +} from '@/tools/clickup/shared' +import type { ClickUpGetListsParams, ClickUpListListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetListsTool: ToolConfig = { + id: 'clickup_get_lists', + name: 'ClickUp Get Lists', + description: + 'List the lists in a ClickUp folder, or the folderless lists in a space when a space ID is provided instead', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + folderId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ID of the folder to list lists from (provide this or spaceId; folderId takes precedence when both are set)', + }, + spaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the space to list folderless lists from (provide this or folderId)', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return archived lists', + }, + }, + + request: { + url: (params) => { + const base = params.folderId + ? `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(params.folderId)}/list` + : params.spaceId + ? `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(params.spaceId)}/list` + : null + + if (!base) { + throw new Error('Either a folder ID or a space ID is required to get lists') + } + + const url = new URL(base) + if (params.archived !== undefined) url.searchParams.set('archived', String(params.archived)) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get lists') + return { success: false, output: { error }, error } + } + + const rawLists = Array.isArray(data?.lists) ? data.lists : [] + + return { + success: true, + output: { lists: rawLists.map((list: unknown) => mapClickUpList(list)) }, + } + }, + + outputs: { + lists: { + type: 'array', + description: 'Lists in the folder or space', + optional: true, + items: { + type: 'object', + properties: CLICKUP_LIST_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_running_timer.ts b/apps/sim/tools/clickup/get_running_timer.ts new file mode 100644 index 00000000000..9c616839068 --- /dev/null +++ b/apps/sim/tools/clickup/get_running_timer.ts @@ -0,0 +1,88 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { ClickUpGetRunningTimerParams, ClickUpTimeEntryResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetRunningTimerTool: ToolConfig< + ClickUpGetRunningTimerParams, + ClickUpTimeEntryResponse +> = { + id: 'clickup_get_running_timer', + name: 'ClickUp Get Running Timer', + description: + 'Get the currently running time entry in a ClickUp workspace (null when no timer is running)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to check', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to check instead of the authenticated user (owners/admins only)', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries/current` + ) + if (params.assignee !== undefined) url.searchParams.set('assignee', String(params.assignee)) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get running timer') + return { success: false, output: { error }, error } + } + + const entry = isRecordLike(data) && isRecordLike(data.data) ? data.data : null + + return { + success: true, + output: { timeEntry: entry ? mapClickUpTimeEntry(entry) : null }, + } + }, + + outputs: { + timeEntry: { + type: 'json', + description: + 'The running time entry (duration is negative while running); null when no timer is running', + optional: true, + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_space_tags.ts b/apps/sim/tools/clickup/get_space_tags.ts new file mode 100644 index 00000000000..5b138e7dd17 --- /dev/null +++ b/apps/sim/tools/clickup/get_space_tags.ts @@ -0,0 +1,84 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TAG_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTag, +} from '@/tools/clickup/shared' +import type { + ClickUpGetSpaceTagsParams, + ClickUpTag, + ClickUpTagListResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetSpaceTagsTool: ToolConfig< + ClickUpGetSpaceTagsParams, + ClickUpTagListResponse +> = { + id: 'clickup_get_space_tags', + name: 'ClickUp Get Space Tags', + description: 'List the task tags available in a ClickUp space', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + spaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space to list tags from', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(params.spaceId)}/tag`, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get space tags') + return { success: false, output: { error }, error } + } + + const rawTags = Array.isArray(data?.tags) ? data.tags : [] + + return { + success: true, + output: { + tags: rawTags + .map((tag: unknown) => mapClickUpTag(tag)) + .filter((tag: ClickUpTag | null): tag is ClickUpTag => tag !== null), + }, + } + }, + + outputs: { + tags: { + type: 'array', + description: 'Tags available in the space', + optional: true, + items: { + type: 'object', + properties: CLICKUP_TAG_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_spaces.ts b/apps/sim/tools/clickup/get_spaces.ts new file mode 100644 index 00000000000..b2ebd414f2d --- /dev/null +++ b/apps/sim/tools/clickup/get_spaces.ts @@ -0,0 +1,105 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpSpace, +} from '@/tools/clickup/shared' +import type { ClickUpGetSpacesParams, ClickUpSpaceListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetSpacesTool: ToolConfig = { + id: 'clickup_get_spaces', + name: 'ClickUp Get Spaces', + description: 'List the spaces in a ClickUp workspace', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to list spaces from', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return archived spaces', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/space` + ) + if (params.archived !== undefined) url.searchParams.set('archived', String(params.archived)) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get spaces') + return { success: false, output: { error }, error } + } + + const rawSpaces = Array.isArray(data?.spaces) ? data.spaces : [] + + return { + success: true, + output: { spaces: rawSpaces.map((space: unknown) => mapClickUpSpace(space)) }, + } + }, + + outputs: { + spaces: { + type: 'array', + description: 'Spaces in the workspace', + optional: true, + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Space ID' }, + name: { type: 'string', description: 'Space name', nullable: true }, + private: { type: 'boolean', description: 'Whether the space is private', nullable: true }, + archived: { + type: 'boolean', + description: 'Whether the space is archived', + nullable: true, + }, + statuses: { + type: 'array', + description: 'Task statuses available in the space', + items: { + type: 'object', + properties: { + status: { type: 'string', description: 'Status name', nullable: true }, + color: { type: 'string', description: 'Status color', nullable: true }, + type: { type: 'string', description: 'Status type', nullable: true }, + }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_task.ts b/apps/sim/tools/clickup/get_task.ts new file mode 100644 index 00000000000..658cb0e08c7 --- /dev/null +++ b/apps/sim/tools/clickup/get_task.ts @@ -0,0 +1,92 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TASK_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTask, +} from '@/tools/clickup/shared' +import type { ClickUpGetTaskParams, ClickUpTaskResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetTaskTool: ToolConfig = { + id: 'clickup_get_task', + name: 'ClickUp Get Task', + description: 'Retrieve a task from ClickUp by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to retrieve', + }, + includeSubtasks: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include subtasks in the response', + }, + includeMarkdownDescription: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return the task description in Markdown format', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}`) + if (params.includeSubtasks !== undefined) { + url.searchParams.set('include_subtasks', String(params.includeSubtasks)) + } + if (params.includeMarkdownDescription !== undefined) { + url.searchParams.set( + 'include_markdown_description', + String(params.includeMarkdownDescription) + ) + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { task: mapClickUpTask(data) }, + } + }, + + outputs: { + task: { + type: 'json', + description: 'The requested task', + optional: true, + properties: CLICKUP_TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_task_members.ts b/apps/sim/tools/clickup/get_task_members.ts new file mode 100644 index 00000000000..57112c5767f --- /dev/null +++ b/apps/sim/tools/clickup/get_task_members.ts @@ -0,0 +1,76 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_MEMBER_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpMember, +} from '@/tools/clickup/shared' +import type { ClickUpGetTaskMembersParams, ClickUpMemberListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetTaskMembersTool: ToolConfig< + ClickUpGetTaskMembersParams, + ClickUpMemberListResponse +> = { + id: 'clickup_get_task_members', + name: 'ClickUp Get Task Members', + description: 'List the workspace members who have explicit access to a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to list members for', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/member`, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get task members') + return { success: false, output: { error }, error } + } + + const rawMembers = Array.isArray(data?.members) ? data.members : [] + + return { + success: true, + output: { members: rawMembers.map((member: unknown) => mapClickUpMember(member)) }, + } + }, + + outputs: { + members: { + type: 'array', + description: 'Members with explicit access to the task', + optional: true, + items: { + type: 'object', + properties: CLICKUP_MEMBER_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_tasks.ts b/apps/sim/tools/clickup/get_tasks.ts new file mode 100644 index 00000000000..ab63dd1f1cf --- /dev/null +++ b/apps/sim/tools/clickup/get_tasks.ts @@ -0,0 +1,190 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TASK_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTask, +} from '@/tools/clickup/shared' +import type { ClickUpGetTasksParams, ClickUpTaskListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetTasksTool: ToolConfig = { + id: 'clickup_get_tasks', + name: 'ClickUp Get Tasks', + description: + 'List the tasks in a ClickUp list (100 tasks per page; increment page until an empty result to paginate)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the list to fetch tasks from', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page to fetch (starts at 0)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order by field: id, created, updated, or due_date', + }, + reverse: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return tasks in reverse order', + }, + subtasks: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include subtasks (excluded by default)', + }, + includeClosed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include closed tasks (excluded by default)', + }, + includeMarkdownDescription: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return task descriptions in Markdown format', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return archived tasks', + }, + statuses: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by status names', + items: { + type: 'string', + description: 'A status name', + }, + }, + assignees: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by assignee user IDs', + items: { + type: 'string', + description: 'A ClickUp user ID', + }, + }, + tags: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by tag names', + items: { + type: 'string', + description: 'A tag name', + }, + }, + dueDateGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only tasks due after this Unix timestamp in milliseconds', + }, + dueDateLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only tasks due before this Unix timestamp in milliseconds', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${CLICKUP_API_BASE_URL}/list/${encodeURIComponent(params.listId)}/task`) + if (params.page !== undefined) url.searchParams.set('page', String(params.page)) + if (params.orderBy) url.searchParams.set('order_by', params.orderBy) + if (params.reverse !== undefined) url.searchParams.set('reverse', String(params.reverse)) + if (params.subtasks !== undefined) url.searchParams.set('subtasks', String(params.subtasks)) + if (params.includeClosed !== undefined) { + url.searchParams.set('include_closed', String(params.includeClosed)) + } + if (params.includeMarkdownDescription !== undefined) { + url.searchParams.set( + 'include_markdown_description', + String(params.includeMarkdownDescription) + ) + } + if (params.archived !== undefined) url.searchParams.set('archived', String(params.archived)) + for (const status of params.statuses ?? []) { + url.searchParams.append('statuses[]', status) + } + for (const assignee of params.assignees ?? []) { + url.searchParams.append('assignees[]', assignee) + } + for (const tag of params.tags ?? []) { + url.searchParams.append('tags[]', tag) + } + if (params.dueDateGt !== undefined) { + url.searchParams.set('due_date_gt', String(params.dueDateGt)) + } + if (params.dueDateLt !== undefined) { + url.searchParams.set('due_date_lt', String(params.dueDateLt)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get tasks') + return { success: false, output: { error }, error } + } + + const rawTasks = Array.isArray(data?.tasks) ? data.tasks : [] + + return { + success: true, + output: { tasks: rawTasks.map((task: unknown) => mapClickUpTask(task)) }, + } + }, + + outputs: { + tasks: { + type: 'array', + description: 'Tasks in the list', + optional: true, + items: { + type: 'object', + properties: CLICKUP_TASK_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_time_entries.ts b/apps/sim/tools/clickup/get_time_entries.ts new file mode 100644 index 00000000000..5f447637feb --- /dev/null +++ b/apps/sim/tools/clickup/get_time_entries.ts @@ -0,0 +1,155 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { + ClickUpGetTimeEntriesParams, + ClickUpTimeEntryListResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetTimeEntriesTool: ToolConfig< + ClickUpGetTimeEntriesParams, + ClickUpTimeEntryListResponse +> = { + id: 'clickup_get_time_entries', + name: 'ClickUp Get Time Entries', + description: + 'List time entries in a ClickUp workspace within a date range (defaults to the last 30 days for the authenticated user)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to list time entries from', + }, + startDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Start of the date range as a Unix timestamp in milliseconds', + }, + endDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'End of the date range as a Unix timestamp in milliseconds', + }, + assignee: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter by user IDs, comma-separated (requires workspace owner/admin to view others)', + }, + taskId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries for this task (use at most one location filter)', + }, + listId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries in this list (use at most one location filter)', + }, + folderId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries in this folder (use at most one location filter)', + }, + spaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries in this space (use at most one location filter)', + }, + includeTaskTags: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include task tags in the response', + }, + includeLocationNames: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include list, folder, and space names in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries` + ) + if (params.startDate !== undefined) { + url.searchParams.set('start_date', String(params.startDate)) + } + if (params.endDate !== undefined) url.searchParams.set('end_date', String(params.endDate)) + if (params.assignee) url.searchParams.set('assignee', params.assignee) + if (params.taskId) url.searchParams.set('task_id', params.taskId) + if (params.listId) url.searchParams.set('list_id', params.listId) + if (params.folderId) url.searchParams.set('folder_id', params.folderId) + if (params.spaceId) url.searchParams.set('space_id', params.spaceId) + if (params.includeTaskTags !== undefined) { + url.searchParams.set('include_task_tags', String(params.includeTaskTags)) + } + if (params.includeLocationNames !== undefined) { + url.searchParams.set('include_location_names', String(params.includeLocationNames)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get time entries') + return { success: false, output: { error }, error } + } + + const rawEntries = Array.isArray(data?.data) ? data.data : [] + + return { + success: true, + output: { timeEntries: rawEntries.map((entry: unknown) => mapClickUpTimeEntry(entry)) }, + } + }, + + outputs: { + timeEntries: { + type: 'array', + description: 'Time entries in the date range', + optional: true, + items: { + type: 'object', + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_workspaces.ts b/apps/sim/tools/clickup/get_workspaces.ts new file mode 100644 index 00000000000..36d78e8de9f --- /dev/null +++ b/apps/sim/tools/clickup/get_workspaces.ts @@ -0,0 +1,77 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpWorkspace, +} from '@/tools/clickup/shared' +import type { + ClickUpGetWorkspacesParams, + ClickUpWorkspaceListResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetWorkspacesTool: ToolConfig< + ClickUpGetWorkspacesParams, + ClickUpWorkspaceListResponse +> = { + id: 'clickup_get_workspaces', + name: 'ClickUp Get Workspaces', + description: 'List the ClickUp workspaces (teams) available to the connected account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + }, + + request: { + url: `${CLICKUP_API_BASE_URL}/team`, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get workspaces') + return { success: false, output: { error }, error } + } + + const rawTeams = Array.isArray(data?.teams) ? data.teams : [] + + return { + success: true, + output: { workspaces: rawTeams.map((team: unknown) => mapClickUpWorkspace(team)) }, + } + }, + + outputs: { + workspaces: { + type: 'array', + description: 'Workspaces available to the connected account', + optional: true, + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Workspace ID' }, + name: { type: 'string', description: 'Workspace name', nullable: true }, + color: { type: 'string', description: 'Workspace color', nullable: true }, + avatar: { type: 'string', description: 'Workspace avatar URL', nullable: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/index.ts b/apps/sim/tools/clickup/index.ts new file mode 100644 index 00000000000..d01a279b2bf --- /dev/null +++ b/apps/sim/tools/clickup/index.ts @@ -0,0 +1,77 @@ +import { clickupAddTagToTaskTool } from '@/tools/clickup/add_tag_to_task' +import { clickupCreateChecklistTool } from '@/tools/clickup/create_checklist' +import { clickupCreateChecklistItemTool } from '@/tools/clickup/create_checklist_item' +import { clickupCreateCommentTool } from '@/tools/clickup/create_comment' +import { clickupCreateFolderTool } from '@/tools/clickup/create_folder' +import { clickupCreateListTool } from '@/tools/clickup/create_list' +import { clickupCreateTaskTool } from '@/tools/clickup/create_task' +import { clickupCreateTimeEntryTool } from '@/tools/clickup/create_time_entry' +import { clickupDeleteChecklistTool } from '@/tools/clickup/delete_checklist' +import { clickupDeleteChecklistItemTool } from '@/tools/clickup/delete_checklist_item' +import { clickupDeleteCommentTool } from '@/tools/clickup/delete_comment' +import { clickupDeleteTaskTool } from '@/tools/clickup/delete_task' +import { clickupDeleteTimeEntryTool } from '@/tools/clickup/delete_time_entry' +import { clickupGetCommentsTool } from '@/tools/clickup/get_comments' +import { clickupGetCustomFieldsTool } from '@/tools/clickup/get_custom_fields' +import { clickupGetFoldersTool } from '@/tools/clickup/get_folders' +import { clickupGetListMembersTool } from '@/tools/clickup/get_list_members' +import { clickupGetListsTool } from '@/tools/clickup/get_lists' +import { clickupGetRunningTimerTool } from '@/tools/clickup/get_running_timer' +import { clickupGetSpaceTagsTool } from '@/tools/clickup/get_space_tags' +import { clickupGetSpacesTool } from '@/tools/clickup/get_spaces' +import { clickupGetTaskTool } from '@/tools/clickup/get_task' +import { clickupGetTaskMembersTool } from '@/tools/clickup/get_task_members' +import { clickupGetTasksTool } from '@/tools/clickup/get_tasks' +import { clickupGetTimeEntriesTool } from '@/tools/clickup/get_time_entries' +import { clickupGetWorkspacesTool } from '@/tools/clickup/get_workspaces' +import { clickupRemoveCustomFieldValueTool } from '@/tools/clickup/remove_custom_field_value' +import { clickupRemoveTagFromTaskTool } from '@/tools/clickup/remove_tag_from_task' +import { clickupSearchTasksTool } from '@/tools/clickup/search_tasks' +import { clickupSetCustomFieldValueTool } from '@/tools/clickup/set_custom_field_value' +import { clickupStartTimerTool } from '@/tools/clickup/start_timer' +import { clickupStopTimerTool } from '@/tools/clickup/stop_timer' +import { clickupUpdateChecklistTool } from '@/tools/clickup/update_checklist' +import { clickupUpdateChecklistItemTool } from '@/tools/clickup/update_checklist_item' +import { clickupUpdateCommentTool } from '@/tools/clickup/update_comment' +import { clickupUpdateTaskTool } from '@/tools/clickup/update_task' +import { clickupUpdateTimeEntryTool } from '@/tools/clickup/update_time_entry' +import { clickupUploadAttachmentTool } from '@/tools/clickup/upload_attachment' + +export { clickupAddTagToTaskTool } +export { clickupCreateChecklistTool } +export { clickupCreateChecklistItemTool } +export { clickupCreateCommentTool } +export { clickupCreateFolderTool } +export { clickupCreateListTool } +export { clickupCreateTaskTool } +export { clickupCreateTimeEntryTool } +export { clickupDeleteChecklistTool } +export { clickupDeleteChecklistItemTool } +export { clickupDeleteCommentTool } +export { clickupDeleteTaskTool } +export { clickupDeleteTimeEntryTool } +export { clickupGetCommentsTool } +export { clickupGetCustomFieldsTool } +export { clickupGetFoldersTool } +export { clickupGetListMembersTool } +export { clickupGetListsTool } +export { clickupGetRunningTimerTool } +export { clickupGetSpaceTagsTool } +export { clickupGetSpacesTool } +export { clickupGetTaskTool } +export { clickupGetTaskMembersTool } +export { clickupGetTasksTool } +export { clickupGetTimeEntriesTool } +export { clickupGetWorkspacesTool } +export { clickupRemoveCustomFieldValueTool } +export { clickupRemoveTagFromTaskTool } +export { clickupSearchTasksTool } +export { clickupSetCustomFieldValueTool } +export { clickupStartTimerTool } +export { clickupStopTimerTool } +export { clickupUpdateChecklistTool } +export { clickupUpdateChecklistItemTool } +export { clickupUpdateCommentTool } +export { clickupUpdateTaskTool } +export { clickupUpdateTimeEntryTool } +export { clickupUploadAttachmentTool } diff --git a/apps/sim/tools/clickup/remove_custom_field_value.ts b/apps/sim/tools/clickup/remove_custom_field_value.ts new file mode 100644 index 00000000000..63f44764198 --- /dev/null +++ b/apps/sim/tools/clickup/remove_custom_field_value.ts @@ -0,0 +1,83 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpCustomFieldValueResponse, + ClickUpRemoveCustomFieldValueParams, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupRemoveCustomFieldValueTool: ToolConfig< + ClickUpRemoveCustomFieldValueParams, + ClickUpCustomFieldValueResponse +> = { + id: 'clickup_remove_custom_field_value', + name: 'ClickUp Remove Custom Field Value', + description: + 'Remove the value of a custom field from a ClickUp task (does not delete the field itself)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to remove the custom field value from', + }, + fieldId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the custom field to clear', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/field/${encodeURIComponent(params.fieldId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage( + response, + data, + 'Failed to remove custom field value' + ) + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { taskId: params?.taskId, fieldId: params?.fieldId }, + } + }, + + outputs: { + taskId: { type: 'string', description: 'ID of the updated task', optional: true }, + fieldId: { + type: 'string', + description: 'ID of the custom field that was cleared', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/clickup/remove_tag_from_task.ts b/apps/sim/tools/clickup/remove_tag_from_task.ts new file mode 100644 index 00000000000..b34a984c996 --- /dev/null +++ b/apps/sim/tools/clickup/remove_tag_from_task.ts @@ -0,0 +1,71 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpTaskTagParams, ClickUpTaskTagResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupRemoveTagFromTaskTool: ToolConfig< + ClickUpTaskTagParams, + ClickUpTaskTagResponse +> = { + id: 'clickup_remove_tag_from_task', + name: 'ClickUp Remove Tag From Task', + description: 'Remove a tag from a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to remove the tag from', + }, + tagName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the tag to remove', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/tag/${encodeURIComponent(params.tagName)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to remove tag from task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { taskId: params?.taskId, tagName: params?.tagName }, + } + }, + + outputs: { + taskId: { type: 'string', description: 'ID of the task', optional: true }, + tagName: { type: 'string', description: 'Name of the tag that was removed', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/search_tasks.ts b/apps/sim/tools/clickup/search_tasks.ts new file mode 100644 index 00000000000..1247add76e1 --- /dev/null +++ b/apps/sim/tools/clickup/search_tasks.ts @@ -0,0 +1,227 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TASK_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTask, +} from '@/tools/clickup/shared' +import type { ClickUpSearchTasksParams, ClickUpTaskListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupSearchTasksTool: ToolConfig = + { + id: 'clickup_search_tasks', + name: 'ClickUp Search Tasks', + description: + 'Search tasks across a ClickUp workspace with filters for lists, folders, spaces, statuses, assignees, tags, and due dates (100 tasks per page; increment page until an empty result to paginate)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to search tasks in', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page to fetch (starts at 0)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order by field: id, created, updated, or due_date', + }, + reverse: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return tasks in reverse order', + }, + subtasks: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include subtasks (excluded by default)', + }, + includeClosed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include closed tasks (excluded by default)', + }, + includeMarkdownDescription: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return task descriptions in Markdown format', + }, + listIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter by list IDs', + items: { + type: 'string', + description: 'A ClickUp list ID', + }, + }, + spaceIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter by space IDs', + items: { + type: 'string', + description: 'A ClickUp space ID', + }, + }, + folderIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter by folder IDs', + items: { + type: 'string', + description: 'A ClickUp folder ID', + }, + }, + statuses: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by status names', + items: { + type: 'string', + description: 'A status name', + }, + }, + assignees: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by assignee user IDs', + items: { + type: 'string', + description: 'A ClickUp user ID', + }, + }, + tags: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by tag names', + items: { + type: 'string', + description: 'A tag name', + }, + }, + dueDateGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only tasks due after this Unix timestamp in milliseconds', + }, + dueDateLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only tasks due before this Unix timestamp in milliseconds', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/task` + ) + if (params.page !== undefined) url.searchParams.set('page', String(params.page)) + if (params.orderBy) url.searchParams.set('order_by', params.orderBy) + if (params.reverse !== undefined) url.searchParams.set('reverse', String(params.reverse)) + if (params.subtasks !== undefined) { + url.searchParams.set('subtasks', String(params.subtasks)) + } + if (params.includeClosed !== undefined) { + url.searchParams.set('include_closed', String(params.includeClosed)) + } + if (params.includeMarkdownDescription !== undefined) { + url.searchParams.set( + 'include_markdown_description', + String(params.includeMarkdownDescription) + ) + } + for (const listId of params.listIds ?? []) { + url.searchParams.append('list_ids[]', listId) + } + for (const spaceId of params.spaceIds ?? []) { + url.searchParams.append('space_ids[]', spaceId) + } + for (const folderId of params.folderIds ?? []) { + url.searchParams.append('project_ids[]', folderId) + } + for (const status of params.statuses ?? []) { + url.searchParams.append('statuses[]', status) + } + for (const assignee of params.assignees ?? []) { + url.searchParams.append('assignees[]', assignee) + } + for (const tag of params.tags ?? []) { + url.searchParams.append('tags[]', tag) + } + if (params.dueDateGt !== undefined) { + url.searchParams.set('due_date_gt', String(params.dueDateGt)) + } + if (params.dueDateLt !== undefined) { + url.searchParams.set('due_date_lt', String(params.dueDateLt)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to search tasks') + return { success: false, output: { error }, error } + } + + const rawTasks = Array.isArray(data?.tasks) ? data.tasks : [] + + return { + success: true, + output: { tasks: rawTasks.map((task: unknown) => mapClickUpTask(task)) }, + } + }, + + outputs: { + tasks: { + type: 'array', + description: 'Tasks matching the filters', + optional: true, + items: { + type: 'object', + properties: CLICKUP_TASK_OUTPUT_PROPERTIES, + }, + }, + }, + } diff --git a/apps/sim/tools/clickup/set_custom_field_value.ts b/apps/sim/tools/clickup/set_custom_field_value.ts new file mode 100644 index 00000000000..cd446d5471c --- /dev/null +++ b/apps/sim/tools/clickup/set_custom_field_value.ts @@ -0,0 +1,84 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpCustomFieldValueResponse, + ClickUpSetCustomFieldValueParams, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupSetCustomFieldValueTool: ToolConfig< + ClickUpSetCustomFieldValueParams, + ClickUpCustomFieldValueResponse +> = { + id: 'clickup_set_custom_field_value', + name: 'ClickUp Set Custom Field Value', + description: + 'Set the value of a custom field on a ClickUp task (the value shape depends on the field type)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to set the custom field on', + }, + fieldId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'UUID of the custom field (find it with the Get Custom Fields or Get Task operations)', + }, + value: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Value to set. The shape depends on the field type: text/number fields take a plain value, label fields take an array of option UUIDs, dropdown fields take an option UUID', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/field/${encodeURIComponent(params.fieldId)}`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => ({ value: params.value }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to set custom field value') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { taskId: params?.taskId, fieldId: params?.fieldId }, + } + }, + + outputs: { + taskId: { type: 'string', description: 'ID of the updated task', optional: true }, + fieldId: { type: 'string', description: 'ID of the custom field that was set', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/shared.ts b/apps/sim/tools/clickup/shared.ts new file mode 100644 index 00000000000..2d3cebec811 --- /dev/null +++ b/apps/sim/tools/clickup/shared.ts @@ -0,0 +1,707 @@ +import { isRecordLike } from '@sim/utils/object' +import type { + ClickUpAttachment, + ClickUpChecklist, + ClickUpChecklistItem, + ClickUpComment, + ClickUpCustomField, + ClickUpFolder, + ClickUpList, + ClickUpMember, + ClickUpPriority, + ClickUpSpace, + ClickUpStatus, + ClickUpTag, + ClickUpTask, + ClickUpTimeEntry, + ClickUpUser, + ClickUpWorkspace, +} from '@/tools/clickup/types' +import type { OutputProperty } from '@/tools/types' + +export const CLICKUP_API_BASE_URL = 'https://api.clickup.com/api/v2' + +/** + * Builds the Authorization header value for ClickUp API requests. + * + * ClickUp documents two credential shapes: OAuth access tokens are sent as + * `Authorization: Bearer `, while personal API tokens (prefixed with + * `pk_`) must be sent bare as `Authorization: ` with no scheme. + * This helper detects the personal-token prefix and returns the correct form + * so tools work with both OAuth connections and pasted API tokens. + * + * @param accessToken - OAuth access token or ClickUp personal API token + * @returns The value to use for the `Authorization` header + */ +export function clickupAuthorizationHeader(accessToken: string): string { + return accessToken.startsWith('pk_') ? accessToken : `Bearer ${accessToken}` +} + +function getRequiredString(value: unknown, field: string): string { + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + + throw new Error(`ClickUp response is missing required field: ${field}`) +} + +function getOptionalString(value: unknown): string | null { + if (typeof value === 'string') { + return value + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + + return null +} + +function getOptionalBoolean(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} + +function getOptionalNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + + return null +} + +const CLICKUP_USER_OUTPUT_PROPERTIES: Record = { + id: { type: 'number', description: 'User ID', nullable: true }, + username: { type: 'string', description: 'Username', nullable: true }, + email: { type: 'string', description: 'User email', nullable: true }, + profilePicture: { type: 'string', description: 'Profile picture URL', nullable: true }, +} + +export const CLICKUP_TASK_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Task ID' }, + customId: { type: 'string', description: 'Custom task ID', nullable: true }, + name: { type: 'string', description: 'Task name' }, + textContent: { type: 'string', description: 'Plain text content', nullable: true }, + description: { type: 'string', description: 'Task description', nullable: true }, + markdownDescription: { + type: 'string', + description: 'Task description in Markdown (present when requested)', + nullable: true, + }, + status: { + type: 'object', + description: 'Task status', + nullable: true, + properties: { + status: { type: 'string', description: 'Status name', nullable: true }, + color: { type: 'string', description: 'Status color', nullable: true }, + type: { type: 'string', description: 'Status type (open, closed, custom)', nullable: true }, + }, + }, + archived: { type: 'boolean', description: 'Whether the task is archived' }, + creator: { + type: 'object', + description: 'Task creator', + nullable: true, + properties: CLICKUP_USER_OUTPUT_PROPERTIES, + }, + assignees: { + type: 'array', + description: 'Users assigned to the task', + items: { type: 'object', properties: CLICKUP_USER_OUTPUT_PROPERTIES }, + }, + watchers: { + type: 'array', + description: 'Users watching the task', + items: { type: 'object', properties: CLICKUP_USER_OUTPUT_PROPERTIES }, + }, + tags: { + type: 'array', + description: 'Tags applied to the task', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Tag name', nullable: true }, + tagFg: { type: 'string', description: 'Tag foreground color', nullable: true }, + tagBg: { type: 'string', description: 'Tag background color', nullable: true }, + }, + }, + }, + parent: { type: 'string', description: 'Parent task ID', nullable: true }, + priority: { + type: 'object', + description: 'Task priority', + nullable: true, + properties: { + id: { type: 'string', description: 'Priority ID', nullable: true }, + priority: { type: 'string', description: 'Priority name', nullable: true }, + color: { type: 'string', description: 'Priority color', nullable: true }, + }, + }, + dueDate: { type: 'string', description: 'Due date (Unix ms)', nullable: true }, + startDate: { type: 'string', description: 'Start date (Unix ms)', nullable: true }, + points: { type: 'number', description: 'Sprint points', nullable: true }, + timeEstimate: { type: 'number', description: 'Time estimate in milliseconds', nullable: true }, + timeSpent: { type: 'number', description: 'Time tracked in milliseconds', nullable: true }, + customFields: { + type: 'json', + description: 'Custom field values on the task (id, name, type, value)', + }, + dateCreated: { type: 'string', description: 'Creation timestamp (Unix ms)', nullable: true }, + dateUpdated: { type: 'string', description: 'Last update timestamp (Unix ms)', nullable: true }, + dateClosed: { type: 'string', description: 'Closed timestamp (Unix ms)', nullable: true }, + dateDone: { type: 'string', description: 'Done timestamp (Unix ms)', nullable: true }, + list: { + type: 'object', + description: 'List containing the task', + nullable: true, + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name', nullable: true }, + }, + }, + folder: { + type: 'object', + description: 'Folder containing the task', + nullable: true, + properties: { + id: { type: 'string', description: 'Folder ID' }, + name: { type: 'string', description: 'Folder name', nullable: true }, + }, + }, + space: { + type: 'object', + description: 'Space containing the task', + nullable: true, + properties: { + id: { type: 'string', description: 'Space ID' }, + name: { type: 'string', description: 'Space name', nullable: true }, + }, + }, + url: { type: 'string', description: 'URL to the task in ClickUp', nullable: true }, + subtasks: { + type: 'json', + description: 'Subtasks with the same shape as the task object (present when requested)', + nullable: true, + }, +} + +export const CLICKUP_MEMBER_OUTPUT_PROPERTIES: Record = { + id: { type: 'number', description: 'Member user ID', nullable: true }, + username: { type: 'string', description: 'Username', nullable: true }, + email: { type: 'string', description: 'User email', nullable: true }, + color: { type: 'string', description: 'Profile color', nullable: true }, + initials: { type: 'string', description: 'User initials', nullable: true }, + profilePicture: { type: 'string', description: 'Profile picture URL', nullable: true }, +} + +export const CLICKUP_COMMENT_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Comment ID' }, + commentText: { type: 'string', description: 'Comment text content', nullable: true }, + resolved: { type: 'boolean', description: 'Whether the comment is resolved', nullable: true }, + user: { + type: 'object', + description: 'Comment author', + nullable: true, + properties: CLICKUP_USER_OUTPUT_PROPERTIES, + }, + assignee: { + type: 'object', + description: 'User the comment is assigned to', + nullable: true, + properties: CLICKUP_USER_OUTPUT_PROPERTIES, + }, + date: { type: 'string', description: 'Comment timestamp (Unix ms)', nullable: true }, + replyCount: { type: 'string', description: 'Number of replies', nullable: true }, +} + +export const CLICKUP_LIST_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name', nullable: true }, + taskCount: { type: 'string', description: 'Number of tasks in the list', nullable: true }, + archived: { type: 'boolean', description: 'Whether the list is archived', nullable: true }, +} + +export const CLICKUP_FOLDER_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Folder ID' }, + name: { type: 'string', description: 'Folder name', nullable: true }, + hidden: { type: 'boolean', description: 'Whether the folder is hidden', nullable: true }, + taskCount: { type: 'string', description: 'Number of tasks in the folder', nullable: true }, + space: { + type: 'object', + description: 'Space containing the folder', + nullable: true, + properties: { + id: { type: 'string', description: 'Space ID' }, + name: { type: 'string', description: 'Space name', nullable: true }, + }, + }, +} + +export const CLICKUP_TAG_OUTPUT_PROPERTIES: Record = { + name: { type: 'string', description: 'Tag name', nullable: true }, + tagFg: { type: 'string', description: 'Tag foreground color', nullable: true }, + tagBg: { type: 'string', description: 'Tag background color', nullable: true }, +} + +export function mapClickUpUser(value: unknown): ClickUpUser | null { + if (!isRecordLike(value)) { + return null + } + + return { + id: getOptionalNumber(value.id), + username: getOptionalString(value.username), + email: getOptionalString(value.email), + profilePicture: getOptionalString(value.profilePicture), + } +} + +function mapClickUpStatus(value: unknown): ClickUpStatus | null { + if (!isRecordLike(value)) { + return null + } + + return { + status: getOptionalString(value.status), + color: getOptionalString(value.color), + type: getOptionalString(value.type), + } +} + +function mapClickUpPriority(value: unknown): ClickUpPriority | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return { id: String(value), priority: null, color: null } + } + + if (!isRecordLike(value)) { + return null + } + + return { + id: getOptionalString(value.id), + priority: getOptionalString(value.priority) ?? getOptionalString(value.name), + color: getOptionalString(value.color), + } +} + +export function mapClickUpTag(value: unknown): ClickUpTag | null { + if (!isRecordLike(value)) { + return null + } + + return { + name: getOptionalString(value.name), + tagFg: getOptionalString(value.tag_fg), + tagBg: getOptionalString(value.tag_bg), + } +} + +function mapIdName(value: unknown): { id: string; name: string | null } | null { + if (!isRecordLike(value)) { + return null + } + + const id = getOptionalString(value.id) + if (!id) { + return null + } + + return { id, name: getOptionalString(value.name) } +} + +export function mapClickUpTask(value: unknown): ClickUpTask { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid task object') + } + + const rawAssignees = Array.isArray(value.assignees) ? value.assignees : [] + const rawWatchers = Array.isArray(value.watchers) ? value.watchers : [] + const rawTags = Array.isArray(value.tags) ? value.tags : [] + const rawCustomFields = Array.isArray(value.custom_fields) ? value.custom_fields : [] + + return { + id: getRequiredString(value.id, 'id'), + customId: getOptionalString(value.custom_id), + name: getRequiredString(value.name, 'name'), + textContent: getOptionalString(value.text_content), + description: getOptionalString(value.description), + markdownDescription: getOptionalString(value.markdown_description), + status: mapClickUpStatus(value.status), + archived: typeof value.archived === 'boolean' ? value.archived : false, + creator: mapClickUpUser(value.creator), + assignees: rawAssignees + .map((assignee) => mapClickUpUser(assignee)) + .filter((assignee): assignee is ClickUpUser => assignee !== null), + watchers: rawWatchers + .map((watcher) => mapClickUpUser(watcher)) + .filter((watcher): watcher is ClickUpUser => watcher !== null), + tags: rawTags.map((tag) => mapClickUpTag(tag)).filter((tag): tag is ClickUpTag => tag !== null), + parent: getOptionalString(value.parent), + priority: mapClickUpPriority(value.priority), + dueDate: getOptionalString(value.due_date), + startDate: getOptionalString(value.start_date), + points: getOptionalNumber(value.points), + timeEstimate: getOptionalNumber(value.time_estimate), + timeSpent: getOptionalNumber(value.time_spent), + customFields: rawCustomFields.filter(isRecordLike), + dateCreated: getOptionalString(value.date_created), + dateUpdated: getOptionalString(value.date_updated), + dateClosed: getOptionalString(value.date_closed), + dateDone: getOptionalString(value.date_done), + list: mapIdName(value.list), + folder: mapIdName(value.folder), + space: mapIdName(value.space), + url: getOptionalString(value.url), + subtasks: Array.isArray(value.subtasks) + ? value.subtasks.map((subtask) => mapClickUpTask(subtask)) + : null, + } +} + +export function mapClickUpComment(value: unknown): ClickUpComment { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid comment object') + } + + return { + id: getRequiredString(value.id, 'id'), + commentText: getOptionalString(value.comment_text), + resolved: getOptionalBoolean(value.resolved), + user: mapClickUpUser(value.user), + assignee: mapClickUpUser(value.assignee), + date: getOptionalString(value.date), + replyCount: getOptionalString(value.reply_count), + } +} + +export function mapClickUpWorkspace(value: unknown): ClickUpWorkspace { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid workspace object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + color: getOptionalString(value.color), + avatar: getOptionalString(value.avatar), + } +} + +export function mapClickUpSpace(value: unknown): ClickUpSpace { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid space object') + } + + const rawStatuses = Array.isArray(value.statuses) ? value.statuses : [] + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + private: getOptionalBoolean(value.private), + archived: getOptionalBoolean(value.archived), + statuses: rawStatuses + .map((status) => mapClickUpStatus(status)) + .filter((status): status is ClickUpStatus => status !== null), + } +} + +export function mapClickUpFolder(value: unknown): ClickUpFolder { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid folder object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + hidden: getOptionalBoolean(value.hidden), + taskCount: getOptionalString(value.task_count), + space: mapIdName(value.space), + } +} + +export function mapClickUpList(value: unknown): ClickUpList { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid list object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + taskCount: getOptionalString(value.task_count), + archived: getOptionalBoolean(value.archived), + } +} + +export function mapClickUpAttachment(value: unknown): ClickUpAttachment { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid attachment object') + } + + return { + id: getRequiredString(value.id, 'id'), + version: getOptionalString(value.version), + title: getOptionalString(value.title), + extension: getOptionalString(value.extension), + url: getOptionalString(value.url), + date: getOptionalNumber(value.date), + thumbnailSmall: getOptionalString(value.thumbnail_small), + thumbnailLarge: getOptionalString(value.thumbnail_large), + } +} + +/** + * Maps a member from the task/list member endpoints. The documented shape is a + * flat user object (`{ id, username, email, ... }`); a nested `{ user: {...} }` + * wrapper (the shape the workspace members endpoint uses) is tolerated too. + */ +export function mapClickUpMember(value: unknown): ClickUpMember { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid member object') + } + + const source = isRecordLike(value.user) ? value.user : value + + return { + id: getOptionalNumber(source.id), + username: getOptionalString(source.username), + email: getOptionalString(source.email), + color: getOptionalString(source.color), + initials: getOptionalString(source.initials), + profilePicture: getOptionalString(source.profilePicture), + } +} + +export function mapClickUpCustomField(value: unknown): ClickUpCustomField { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid custom field object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + type: getOptionalString(value.type), + typeConfig: isRecordLike(value.type_config) ? value.type_config : null, + dateCreated: getOptionalString(value.date_created), + hideFromGuests: getOptionalBoolean(value.hide_from_guests), + } +} + +function mapClickUpChecklistItem(value: unknown): ClickUpChecklistItem { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid checklist item object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + orderIndex: getOptionalNumber(value.orderindex), + assignee: mapClickUpUser(value.assignee), + resolved: getOptionalBoolean(value.resolved), + parent: getOptionalString(value.parent), + dateCreated: getOptionalString(value.date_created), + children: Array.isArray(value.children) + ? value.children.filter((child): child is string => typeof child === 'string') + : [], + } +} + +export function mapClickUpChecklist(value: unknown): ClickUpChecklist { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid checklist object') + } + + const rawItems = Array.isArray(value.items) ? value.items : [] + + return { + id: getRequiredString(value.id, 'id'), + taskId: getOptionalString(value.task_id), + name: getOptionalString(value.name), + orderIndex: getOptionalNumber(value.orderindex), + resolved: getOptionalNumber(value.resolved), + unresolved: getOptionalNumber(value.unresolved), + dateCreated: getOptionalString(value.date_created), + items: rawItems.map((item) => mapClickUpChecklistItem(item)), + } +} + +export function mapClickUpTimeEntry(value: unknown): ClickUpTimeEntry { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid time entry object') + } + + const rawTags = Array.isArray(value.tags) ? value.tags : [] + const rawTaskTags = Array.isArray(value.task_tags) ? value.task_tags : [] + const rawLocation = isRecordLike(value.task_location) ? value.task_location : null + + return { + id: getRequiredString(value.id, 'id'), + task: mapIdName(value.task), + workspaceId: getOptionalString(value.wid), + user: mapClickUpUser(value.user), + billable: getOptionalBoolean(value.billable), + start: getOptionalString(value.start), + end: getOptionalString(value.end), + duration: getOptionalNumber(value.duration), + description: getOptionalString(value.description), + tags: rawTags.map((tag) => mapClickUpTag(tag)).filter((tag): tag is ClickUpTag => tag !== null), + source: getOptionalString(value.source), + at: getOptionalString(value.at), + taskUrl: getOptionalString(value.task_url), + taskTags: rawTaskTags + .map((tag) => mapClickUpTag(tag)) + .filter((tag): tag is ClickUpTag => tag !== null), + taskLocation: rawLocation + ? { + listId: getOptionalString(rawLocation.list_id), + folderId: getOptionalString(rawLocation.folder_id), + spaceId: getOptionalString(rawLocation.space_id), + listName: getOptionalString(rawLocation.list_name), + folderName: getOptionalString(rawLocation.folder_name), + spaceName: getOptionalString(rawLocation.space_name), + } + : null, + } +} + +const CLICKUP_CHECKLIST_ITEM_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Checklist item ID' }, + name: { type: 'string', description: 'Checklist item name', nullable: true }, + orderIndex: { type: 'number', description: 'Order of the item in the checklist', nullable: true }, + assignee: { + type: 'object', + description: 'User the item is assigned to', + nullable: true, + properties: { + id: { type: 'number', description: 'User ID', nullable: true }, + username: { type: 'string', description: 'Username', nullable: true }, + email: { type: 'string', description: 'User email', nullable: true }, + profilePicture: { type: 'string', description: 'Profile picture URL', nullable: true }, + }, + }, + resolved: { type: 'boolean', description: 'Whether the item is resolved', nullable: true }, + parent: { type: 'string', description: 'Parent checklist item ID', nullable: true }, + dateCreated: { type: 'string', description: 'Creation timestamp (Unix ms)', nullable: true }, + children: { + type: 'array', + description: 'IDs of nested child items', + items: { type: 'string', description: 'A checklist item ID' }, + }, +} + +export const CLICKUP_CHECKLIST_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Checklist ID' }, + taskId: { + type: 'string', + description: 'ID of the task the checklist belongs to', + nullable: true, + }, + name: { type: 'string', description: 'Checklist name', nullable: true }, + orderIndex: { type: 'number', description: 'Order of the checklist on the task', nullable: true }, + resolved: { type: 'number', description: 'Number of resolved items', nullable: true }, + unresolved: { type: 'number', description: 'Number of unresolved items', nullable: true }, + dateCreated: { type: 'string', description: 'Creation timestamp (Unix ms)', nullable: true }, + items: { + type: 'array', + description: 'Items in the checklist', + items: { type: 'object', properties: CLICKUP_CHECKLIST_ITEM_OUTPUT_PROPERTIES }, + }, +} + +export const CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Time entry ID' }, + task: { + type: 'object', + description: 'Task the time entry is associated with', + nullable: true, + properties: { + id: { type: 'string', description: 'Task ID' }, + name: { type: 'string', description: 'Task name', nullable: true }, + }, + }, + workspaceId: { type: 'string', description: 'Workspace ID', nullable: true }, + user: { + type: 'object', + description: 'User the time entry belongs to', + nullable: true, + properties: { + id: { type: 'number', description: 'User ID', nullable: true }, + username: { type: 'string', description: 'Username', nullable: true }, + email: { type: 'string', description: 'User email', nullable: true }, + profilePicture: { type: 'string', description: 'Profile picture URL', nullable: true }, + }, + }, + billable: { type: 'boolean', description: 'Whether the entry is billable', nullable: true }, + start: { type: 'string', description: 'Start timestamp (Unix ms)', nullable: true }, + end: { type: 'string', description: 'End timestamp (Unix ms)', nullable: true }, + duration: { + type: 'number', + description: 'Duration in milliseconds (negative while the timer is running)', + nullable: true, + }, + description: { type: 'string', description: 'Time entry description', nullable: true }, + tags: { + type: 'array', + description: 'Time entry tags', + items: { type: 'object', properties: CLICKUP_TAG_OUTPUT_PROPERTIES }, + }, + source: { type: 'string', description: 'Source that created the entry', nullable: true }, + at: { type: 'string', description: 'Last update timestamp (Unix ms)', nullable: true }, + taskUrl: { type: 'string', description: 'URL to the task in ClickUp', nullable: true }, + taskTags: { + type: 'array', + description: 'Tags on the associated task (present when requested)', + items: { type: 'object', properties: CLICKUP_TAG_OUTPUT_PROPERTIES }, + }, + taskLocation: { + type: 'object', + description: 'Location of the associated task (names present when requested)', + nullable: true, + properties: { + listId: { type: 'string', description: 'List ID', nullable: true }, + folderId: { type: 'string', description: 'Folder ID', nullable: true }, + spaceId: { type: 'string', description: 'Space ID', nullable: true }, + listName: { type: 'string', description: 'List name', nullable: true }, + folderName: { type: 'string', description: 'Folder name', nullable: true }, + spaceName: { type: 'string', description: 'Space name', nullable: true }, + }, + }, +} + +export function extractClickUpErrorMessage( + response: Response, + data: unknown, + fallback: string +): string { + if (isRecordLike(data)) { + const message = + typeof data.err === 'string' && data.err.trim().length > 0 + ? data.err + : typeof data.error === 'string' && data.error.trim().length > 0 + ? data.error + : null + const ecode = data.ECODE + + if (message) { + return typeof ecode === 'string' && ecode.trim().length > 0 + ? `${fallback}: ${message} (${ecode})` + : `${fallback}: ${message}` + } + } + + if (response.statusText) { + return `${fallback}: ${response.status} ${response.statusText}` + } + + return `${fallback}: ${response.status}` +} diff --git a/apps/sim/tools/clickup/start_timer.ts b/apps/sim/tools/clickup/start_timer.ts new file mode 100644 index 00000000000..d3ab101c5b4 --- /dev/null +++ b/apps/sim/tools/clickup/start_timer.ts @@ -0,0 +1,111 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { ClickUpStartTimerParams, ClickUpTimeEntryResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupStartTimerTool: ToolConfig = + { + id: 'clickup_start_timer', + name: 'ClickUp Start Timer', + description: 'Start a timer for the authenticated user in a ClickUp workspace', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to start the timer in', + }, + taskId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Task ID to associate the timer with', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the time entry', + }, + billable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the entry is billable', + }, + tags: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Time entry tag names to apply', + items: { + type: 'string', + description: 'A time entry tag name', + }, + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries/start`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.taskId) body.tid = params.taskId + if (params.description) body.description = params.description + if (params.billable !== undefined) body.billable = params.billable + if (params.tags?.length) body.tags = params.tags.map((name) => ({ name })) + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to start timer') + return { success: false, output: { error }, error } + } + + const entry = isRecordLike(data) && isRecordLike(data.data) ? data.data : null + + return { + success: true, + output: { timeEntry: entry ? mapClickUpTimeEntry(entry) : null }, + } + }, + + outputs: { + timeEntry: { + type: 'json', + description: 'The started time entry (duration is negative while running)', + optional: true, + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, + } diff --git a/apps/sim/tools/clickup/stop_timer.ts b/apps/sim/tools/clickup/stop_timer.ts new file mode 100644 index 00000000000..752d3fe1f45 --- /dev/null +++ b/apps/sim/tools/clickup/stop_timer.ts @@ -0,0 +1,72 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { ClickUpStopTimerParams, ClickUpTimeEntryResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupStopTimerTool: ToolConfig = { + id: 'clickup_stop_timer', + name: 'ClickUp Stop Timer', + description: "Stop the authenticated user's currently running timer in a ClickUp workspace", + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) the timer is running in', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries/stop`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to stop timer') + return { success: false, output: { error }, error } + } + + const entry = isRecordLike(data) && isRecordLike(data.data) ? data.data : null + + return { + success: true, + output: { timeEntry: entry ? mapClickUpTimeEntry(entry) : null }, + } + }, + + outputs: { + timeEntry: { + type: 'json', + description: 'The stopped time entry', + optional: true, + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/types.ts b/apps/sim/tools/clickup/types.ts new file mode 100644 index 00000000000..57b980cc2aa --- /dev/null +++ b/apps/sim/tools/clickup/types.ts @@ -0,0 +1,675 @@ +import type { UserFile } from '@/executor/types' +import type { ToolResponse } from '@/tools/types' + +export interface ClickUpUser { + id: number | null + username: string | null + email: string | null + profilePicture: string | null +} + +export interface ClickUpMember { + id: number | null + username: string | null + email: string | null + color: string | null + initials: string | null + profilePicture: string | null +} + +export interface ClickUpStatus { + status: string | null + color: string | null + type: string | null +} + +export interface ClickUpPriority { + id: string | null + priority: string | null + color: string | null +} + +export interface ClickUpTag { + name: string | null + tagFg: string | null + tagBg: string | null +} + +export interface ClickUpTask { + id: string + customId: string | null + name: string + textContent: string | null + description: string | null + markdownDescription: string | null + status: ClickUpStatus | null + archived: boolean + creator: ClickUpUser | null + assignees: ClickUpUser[] + watchers: ClickUpUser[] + tags: ClickUpTag[] + parent: string | null + priority: ClickUpPriority | null + dueDate: string | null + startDate: string | null + points: number | null + timeEstimate: number | null + timeSpent: number | null + customFields: Record[] + dateCreated: string | null + dateUpdated: string | null + dateClosed: string | null + dateDone: string | null + list: { id: string; name: string | null } | null + folder: { id: string; name: string | null } | null + space: { id: string; name: string | null } | null + url: string | null + subtasks: ClickUpTask[] | null +} + +export interface ClickUpComment { + id: string + commentText: string | null + resolved: boolean | null + user: ClickUpUser | null + assignee: ClickUpUser | null + date: string | null + replyCount: string | null +} + +export interface ClickUpWorkspace { + id: string + name: string | null + color: string | null + avatar: string | null +} + +export interface ClickUpSpace { + id: string + name: string | null + private: boolean | null + archived: boolean | null + statuses: ClickUpStatus[] +} + +export interface ClickUpFolder { + id: string + name: string | null + hidden: boolean | null + taskCount: string | null + space: { id: string; name: string | null } | null +} + +export interface ClickUpList { + id: string + name: string | null + taskCount: string | null + archived: boolean | null +} + +export interface ClickUpAttachment { + id: string + version: string | null + title: string | null + extension: string | null + url: string | null + date: number | null + thumbnailSmall: string | null + thumbnailLarge: string | null +} + +export interface ClickUpCustomField { + id: string + name: string | null + type: string | null + typeConfig: Record | null + dateCreated: string | null + hideFromGuests: boolean | null +} + +export interface ClickUpChecklistItem { + id: string + name: string | null + orderIndex: number | null + assignee: ClickUpUser | null + resolved: boolean | null + parent: string | null + dateCreated: string | null + children: string[] +} + +export interface ClickUpChecklist { + id: string + taskId: string | null + name: string | null + orderIndex: number | null + resolved: number | null + unresolved: number | null + dateCreated: string | null + items: ClickUpChecklistItem[] +} + +export interface ClickUpTimeEntryLocation { + listId: string | null + folderId: string | null + spaceId: string | null + listName: string | null + folderName: string | null + spaceName: string | null +} + +export interface ClickUpTimeEntry { + id: string + task: { id: string; name: string | null } | null + workspaceId: string | null + user: ClickUpUser | null + billable: boolean | null + start: string | null + end: string | null + duration: number | null + description: string | null + tags: ClickUpTag[] + source: string | null + at: string | null + taskUrl: string | null + taskTags: ClickUpTag[] + taskLocation: ClickUpTimeEntryLocation | null +} + +export interface ClickUpCreateTaskParams { + accessToken: string + listId: string + name: string + description?: string + markdownContent?: string + status?: string + priority?: number + dueDate?: number + dueDateTime?: boolean + startDate?: number + startDateTime?: boolean + assignees?: number[] + tags?: string[] + timeEstimate?: number + points?: number + parent?: string + notifyAll?: boolean +} + +export interface ClickUpTaskResponse extends ToolResponse { + output: { + task?: ClickUpTask + error?: string + } +} + +export interface ClickUpGetTaskParams { + accessToken: string + taskId: string + includeSubtasks?: boolean + includeMarkdownDescription?: boolean +} + +export interface ClickUpUpdateTaskParams { + accessToken: string + taskId: string + name?: string + description?: string + markdownContent?: string + status?: string + priority?: number + dueDate?: number + dueDateTime?: boolean + startDate?: number + startDateTime?: boolean + timeEstimate?: number + points?: number + parent?: string + archived?: boolean + assigneesToAdd?: number[] + assigneesToRemove?: number[] +} + +export interface ClickUpDeleteTaskParams { + accessToken: string + taskId: string +} + +export interface ClickUpDeleteResponse extends ToolResponse { + output: { + id?: string + deleted?: boolean + error?: string + } +} + +export interface ClickUpGetTasksParams { + accessToken: string + listId: string + page?: number + orderBy?: string + reverse?: boolean + subtasks?: boolean + includeClosed?: boolean + includeMarkdownDescription?: boolean + archived?: boolean + statuses?: string[] + assignees?: string[] + tags?: string[] + dueDateGt?: number + dueDateLt?: number +} + +export interface ClickUpTaskListResponse extends ToolResponse { + output: { + tasks?: ClickUpTask[] + error?: string + } +} + +export interface ClickUpSearchTasksParams { + accessToken: string + workspaceId: string + page?: number + orderBy?: string + reverse?: boolean + subtasks?: boolean + includeClosed?: boolean + includeMarkdownDescription?: boolean + listIds?: string[] + spaceIds?: string[] + folderIds?: string[] + statuses?: string[] + assignees?: string[] + tags?: string[] + dueDateGt?: number + dueDateLt?: number +} + +export interface ClickUpCreateCommentParams { + accessToken: string + taskId: string + commentText: string + assignee?: number + notifyAll?: boolean +} + +export interface ClickUpCreateCommentResponse extends ToolResponse { + output: { + id?: string + histId?: string + date?: number + error?: string + } +} + +export interface ClickUpGetCommentsParams { + accessToken: string + taskId: string + start?: number + startId?: string +} + +export interface ClickUpCommentListResponse extends ToolResponse { + output: { + comments?: ClickUpComment[] + error?: string + } +} + +export interface ClickUpUpdateCommentParams { + accessToken: string + commentId: string + commentText?: string + assignee?: number + resolved?: boolean +} + +export interface ClickUpUpdateCommentResponse extends ToolResponse { + output: { + id?: string + updated?: boolean + error?: string + } +} + +export interface ClickUpDeleteCommentParams { + accessToken: string + commentId: string +} + +export interface ClickUpUploadAttachmentParams { + accessToken: string + taskId: string + file: UserFile | string +} + +export interface ClickUpUploadAttachmentResponse extends ToolResponse { + output: { + attachment?: ClickUpAttachment + files?: UserFile[] + error?: string + } +} + +export interface ClickUpGetWorkspacesParams { + accessToken: string +} + +export interface ClickUpWorkspaceListResponse extends ToolResponse { + output: { + workspaces?: ClickUpWorkspace[] + error?: string + } +} + +export interface ClickUpGetSpacesParams { + accessToken: string + workspaceId: string + archived?: boolean +} + +export interface ClickUpSpaceListResponse extends ToolResponse { + output: { + spaces?: ClickUpSpace[] + error?: string + } +} + +export interface ClickUpGetFoldersParams { + accessToken: string + spaceId: string + archived?: boolean +} + +export interface ClickUpFolderListResponse extends ToolResponse { + output: { + folders?: ClickUpFolder[] + error?: string + } +} + +export interface ClickUpCreateFolderParams { + accessToken: string + spaceId: string + name: string +} + +export interface ClickUpFolderResponse extends ToolResponse { + output: { + folder?: ClickUpFolder + error?: string + } +} + +export interface ClickUpGetListsParams { + accessToken: string + folderId?: string + spaceId?: string + archived?: boolean +} + +export interface ClickUpListListResponse extends ToolResponse { + output: { + lists?: ClickUpList[] + error?: string + } +} + +export interface ClickUpCreateListParams { + accessToken: string + folderId?: string + spaceId?: string + name: string + content?: string + markdownContent?: string +} + +export interface ClickUpListResponse extends ToolResponse { + output: { + list?: ClickUpList + error?: string + } +} + +export interface ClickUpGetSpaceTagsParams { + accessToken: string + spaceId: string +} + +export interface ClickUpTagListResponse extends ToolResponse { + output: { + tags?: ClickUpTag[] + error?: string + } +} + +export interface ClickUpTaskTagParams { + accessToken: string + taskId: string + tagName: string +} + +export interface ClickUpTaskTagResponse extends ToolResponse { + output: { + taskId?: string + tagName?: string + error?: string + } +} + +export interface ClickUpGetTaskMembersParams { + accessToken: string + taskId: string +} + +export interface ClickUpGetListMembersParams { + accessToken: string + listId: string +} + +export interface ClickUpMemberListResponse extends ToolResponse { + output: { + members?: ClickUpMember[] + error?: string + } +} + +export interface ClickUpGetCustomFieldsParams { + accessToken: string + listId: string +} + +export interface ClickUpCustomFieldListResponse extends ToolResponse { + output: { + fields?: ClickUpCustomField[] + error?: string + } +} + +export interface ClickUpSetCustomFieldValueParams { + accessToken: string + taskId: string + fieldId: string + value: unknown +} + +export interface ClickUpCustomFieldValueResponse extends ToolResponse { + output: { + taskId?: string + fieldId?: string + error?: string + } +} + +export interface ClickUpRemoveCustomFieldValueParams { + accessToken: string + taskId: string + fieldId: string +} + +export interface ClickUpCreateChecklistParams { + accessToken: string + taskId: string + name: string +} + +export interface ClickUpChecklistResponse extends ToolResponse { + output: { + checklist?: ClickUpChecklist + error?: string + } +} + +export interface ClickUpUpdateChecklistParams { + accessToken: string + checklistId: string + name?: string + position?: number +} + +export interface ClickUpUpdateChecklistResponse extends ToolResponse { + output: { + id?: string + updated?: boolean + error?: string + } +} + +export interface ClickUpDeleteChecklistParams { + accessToken: string + checklistId: string +} + +export interface ClickUpCreateChecklistItemParams { + accessToken: string + checklistId: string + name: string + assignee?: number +} + +export interface ClickUpUpdateChecklistItemParams { + accessToken: string + checklistId: string + checklistItemId: string + name?: string + assignee?: number + resolved?: boolean + parent?: string +} + +export interface ClickUpDeleteChecklistItemParams { + accessToken: string + checklistId: string + checklistItemId: string +} + +export interface ClickUpGetTimeEntriesParams { + accessToken: string + workspaceId: string + startDate?: number + endDate?: number + assignee?: string + taskId?: string + listId?: string + folderId?: string + spaceId?: string + includeTaskTags?: boolean + includeLocationNames?: boolean +} + +export interface ClickUpTimeEntryListResponse extends ToolResponse { + output: { + timeEntries?: ClickUpTimeEntry[] + error?: string + } +} + +export interface ClickUpCreateTimeEntryParams { + accessToken: string + workspaceId: string + start: number + duration: number + description?: string + billable?: boolean + taskId?: string + assignee?: number +} + +export interface ClickUpTimeEntryResponse extends ToolResponse { + output: { + timeEntry?: ClickUpTimeEntry | null + error?: string + } +} + +export interface ClickUpUpdateTimeEntryParams { + accessToken: string + workspaceId: string + timerId: string + description?: string + start?: number + end?: number + duration?: number + taskId?: string + billable?: boolean +} + +export interface ClickUpUpdateTimeEntryResponse extends ToolResponse { + output: { + id?: string + updated?: boolean + error?: string + } +} + +export interface ClickUpDeleteTimeEntryParams { + accessToken: string + workspaceId: string + timerId: string +} + +export interface ClickUpStartTimerParams { + accessToken: string + workspaceId: string + taskId?: string + description?: string + billable?: boolean + tags?: string[] +} + +export interface ClickUpStopTimerParams { + accessToken: string + workspaceId: string +} + +export interface ClickUpGetRunningTimerParams { + accessToken: string + workspaceId: string + assignee?: number +} + +export type ClickUpResponse = + | ClickUpTaskResponse + | ClickUpTaskListResponse + | ClickUpDeleteResponse + | ClickUpCreateCommentResponse + | ClickUpCommentListResponse + | ClickUpUpdateCommentResponse + | ClickUpUploadAttachmentResponse + | ClickUpWorkspaceListResponse + | ClickUpSpaceListResponse + | ClickUpFolderListResponse + | ClickUpFolderResponse + | ClickUpListListResponse + | ClickUpListResponse + | ClickUpTagListResponse + | ClickUpTaskTagResponse + | ClickUpMemberListResponse + | ClickUpCustomFieldListResponse + | ClickUpCustomFieldValueResponse + | ClickUpChecklistResponse + | ClickUpUpdateChecklistResponse + | ClickUpTimeEntryListResponse + | ClickUpTimeEntryResponse + | ClickUpUpdateTimeEntryResponse diff --git a/apps/sim/tools/clickup/update_checklist.ts b/apps/sim/tools/clickup/update_checklist.ts new file mode 100644 index 00000000000..d135daa6d65 --- /dev/null +++ b/apps/sim/tools/clickup/update_checklist.ts @@ -0,0 +1,91 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpUpdateChecklistParams, + ClickUpUpdateChecklistResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUpdateChecklistTool: ToolConfig< + ClickUpUpdateChecklistParams, + ClickUpUpdateChecklistResponse +> = { + id: 'clickup_update_checklist', + name: 'ClickUp Update Checklist', + description: 'Rename or reorder a checklist on a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the checklist', + }, + position: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New position of the checklist on the task (0 places it first)', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/checklist/${encodeURIComponent(params.checklistId)}`, + method: 'PUT', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.name !== undefined) body.name = params.name + if (params.position !== undefined) body.position = params.position + + if (Object.keys(body).length === 0) { + throw new Error('At least one of name or position is required to update a checklist') + } + + return body + }, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to update checklist') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.checklistId, updated: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the updated checklist', optional: true }, + updated: { type: 'boolean', description: 'Whether the checklist was updated', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/update_checklist_item.ts b/apps/sim/tools/clickup/update_checklist_item.ts new file mode 100644 index 00000000000..4008f0433b3 --- /dev/null +++ b/apps/sim/tools/clickup/update_checklist_item.ts @@ -0,0 +1,122 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpChecklist, +} from '@/tools/clickup/shared' +import type { + ClickUpChecklistResponse, + ClickUpUpdateChecklistItemParams, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUpdateChecklistItemTool: ToolConfig< + ClickUpUpdateChecklistItemParams, + ClickUpChecklistResponse +> = { + id: 'clickup_update_checklist_item', + name: 'ClickUp Update Checklist Item', + description: 'Update a checklist item on a ClickUp task — rename, assign, resolve, or nest it', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist containing the item', + }, + checklistItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist item to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the checklist item', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to assign the item to', + }, + resolved: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the item is resolved', + }, + parent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'UUID of another checklist item to nest this item under', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/checklist/${encodeURIComponent(params.checklistId)}/checklist_item/${encodeURIComponent(params.checklistItemId)}`, + method: 'PUT', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.name !== undefined) body.name = params.name + if (params.assignee !== undefined) body.assignee = params.assignee + if (params.resolved !== undefined) body.resolved = params.resolved + if (params.parent !== undefined) body.parent = params.parent + + if (Object.keys(body).length === 0) { + throw new Error( + 'At least one of name, assignee, resolved, or parent is required to update a checklist item' + ) + } + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to update checklist item') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { checklist: mapClickUpChecklist(isRecordLike(data) ? data.checklist : null) }, + } + }, + + outputs: { + checklist: { + type: 'json', + description: 'The updated checklist including its items', + optional: true, + properties: CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/update_comment.ts b/apps/sim/tools/clickup/update_comment.ts new file mode 100644 index 00000000000..e20f82d3f5d --- /dev/null +++ b/apps/sim/tools/clickup/update_comment.ts @@ -0,0 +1,100 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpUpdateCommentParams, + ClickUpUpdateCommentResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUpdateCommentTool: ToolConfig< + ClickUpUpdateCommentParams, + ClickUpUpdateCommentResponse +> = { + id: 'clickup_update_comment', + name: 'ClickUp Update Comment', + description: 'Update the content, assignee, or resolved state of a ClickUp task comment', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the comment to update', + }, + commentText: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New content for the comment', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to assign the comment to', + }, + resolved: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the comment is resolved', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/comment/${encodeURIComponent(params.commentId)}`, + method: 'PUT', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.commentText !== undefined) body.comment_text = params.commentText + if (params.assignee !== undefined) body.assignee = params.assignee + if (params.resolved !== undefined) body.resolved = params.resolved + + if (Object.keys(body).length === 0) { + throw new Error( + 'At least one of commentText, assignee, or resolved is required to update a comment' + ) + } + + return body + }, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to update comment') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.commentId, updated: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the updated comment', optional: true }, + updated: { type: 'boolean', description: 'Whether the comment was updated', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/update_task.ts b/apps/sim/tools/clickup/update_task.ts new file mode 100644 index 00000000000..a66d7356ad0 --- /dev/null +++ b/apps/sim/tools/clickup/update_task.ts @@ -0,0 +1,195 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TASK_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTask, +} from '@/tools/clickup/shared' +import type { ClickUpTaskResponse, ClickUpUpdateTaskParams } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUpdateTaskTool: ToolConfig = { + id: 'clickup_update_task', + name: 'ClickUp Update Task', + description: 'Update an existing task in ClickUp', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the task', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New plain text description (use a single space to clear)', + }, + markdownContent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New Markdown description (takes precedence over description)', + }, + status: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New status for the task (must exist in the list)', + }, + priority: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Priority: 1 (urgent), 2 (high), 3 (normal), 4 (low)', + }, + dueDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New due date as a Unix timestamp in milliseconds', + }, + dueDateTime: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the due date includes a time of day', + }, + startDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New start date as a Unix timestamp in milliseconds', + }, + startDateTime: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the start date includes a time of day', + }, + timeEstimate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New time estimate in milliseconds', + }, + points: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New sprint points value', + }, + parent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Parent task ID to move this task under (cannot be cleared)', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Set to true to archive the task, false to unarchive', + }, + assigneesToAdd: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs to add as assignees', + items: { + type: 'number', + description: 'A ClickUp user ID', + }, + }, + assigneesToRemove: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs to remove from assignees', + items: { + type: 'number', + description: 'A ClickUp user ID', + }, + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}`, + method: 'PUT', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.name !== undefined) body.name = params.name + if (params.description !== undefined) body.description = params.description + if (params.markdownContent !== undefined) body.markdown_content = params.markdownContent + if (params.status !== undefined) body.status = params.status + if (params.priority !== undefined) body.priority = params.priority + if (params.dueDate !== undefined) body.due_date = params.dueDate + if (params.dueDateTime !== undefined) body.due_date_time = params.dueDateTime + if (params.startDate !== undefined) body.start_date = params.startDate + if (params.startDateTime !== undefined) body.start_date_time = params.startDateTime + if (params.timeEstimate !== undefined) body.time_estimate = params.timeEstimate + if (params.points !== undefined) body.points = params.points + if (params.parent !== undefined) body.parent = params.parent + if (params.archived !== undefined) body.archived = params.archived + if (params.assigneesToAdd?.length || params.assigneesToRemove?.length) { + body.assignees = { + add: params.assigneesToAdd ?? [], + rem: params.assigneesToRemove ?? [], + } + } + + if (Object.keys(body).length === 0) { + throw new Error('At least one field to update is required to update a task') + } + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to update task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { task: mapClickUpTask(data) }, + } + }, + + outputs: { + task: { + type: 'json', + description: 'The updated task', + optional: true, + properties: CLICKUP_TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/update_time_entry.ts b/apps/sim/tools/clickup/update_time_entry.ts new file mode 100644 index 00000000000..1744e982f0a --- /dev/null +++ b/apps/sim/tools/clickup/update_time_entry.ts @@ -0,0 +1,133 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpUpdateTimeEntryParams, + ClickUpUpdateTimeEntryResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUpdateTimeEntryTool: ToolConfig< + ClickUpUpdateTimeEntryParams, + ClickUpUpdateTimeEntryResponse +> = { + id: 'clickup_update_time_entry', + name: 'ClickUp Update Time Entry', + description: + 'Update a time entry in a ClickUp workspace — description, start/end times, task, or billable state', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) the entry belongs to', + }, + timerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the time entry to update', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New description for the entry', + }, + start: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New start (Unix ms); when provided, end must also be provided', + }, + end: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New end (Unix ms); when provided, start must also be provided', + }, + duration: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New duration in milliseconds', + }, + taskId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Task ID to associate the entry with', + }, + billable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the entry is billable', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries/${encodeURIComponent(params.timerId)}`, + method: 'PUT', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.description !== undefined) body.description = params.description + if (params.start !== undefined) body.start = params.start + if (params.end !== undefined) body.end = params.end + if (params.duration !== undefined) body.duration = params.duration + if (params.taskId) body.tid = params.taskId + if (params.billable !== undefined) body.billable = params.billable + + if (Object.keys(body).length === 0) { + throw new Error( + 'At least one of description, start and end, duration, taskId, or billable is required to update a time entry' + ) + } + + if ((params.start === undefined) !== (params.end === undefined)) { + throw new Error('start and end must be provided together when updating entry times') + } + + return body + }, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to update time entry') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.timerId, updated: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the updated time entry', optional: true }, + updated: { type: 'boolean', description: 'Whether the entry was updated', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/upload_attachment.ts b/apps/sim/tools/clickup/upload_attachment.ts new file mode 100644 index 00000000000..4518977516e --- /dev/null +++ b/apps/sim/tools/clickup/upload_attachment.ts @@ -0,0 +1,89 @@ +import type { + ClickUpUploadAttachmentParams, + ClickUpUploadAttachmentResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUploadAttachmentTool: ToolConfig< + ClickUpUploadAttachmentParams, + ClickUpUploadAttachmentResponse +> = { + id: 'clickup_upload_attachment', + name: 'ClickUp Upload Attachment', + description: 'Upload a file to a ClickUp task as an attachment', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to attach the file to', + }, + file: { + type: 'file', + required: true, + visibility: 'user-only', + description: 'File to attach to the task', + }, + }, + + request: { + url: '/api/tools/clickup/upload-attachment', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + taskId: params.taskId, + file: params.file, + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + if (!response.ok || !data?.success) { + throw new Error(data?.error || 'Failed to upload ClickUp attachment') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + attachment: { + type: 'json', + description: 'The created attachment', + optional: true, + properties: { + id: { type: 'string', description: 'Attachment ID' }, + version: { type: 'string', description: 'Attachment version', nullable: true }, + title: { type: 'string', description: 'Attachment title', nullable: true }, + extension: { type: 'string', description: 'File extension', nullable: true }, + url: { type: 'string', description: 'URL of the uploaded attachment', nullable: true }, + date: { + type: 'number', + description: 'Upload timestamp (Unix ms)', + nullable: true, + }, + thumbnailSmall: { type: 'string', description: 'Small thumbnail URL', nullable: true }, + thumbnailLarge: { type: 'string', description: 'Large thumbnail URL', nullable: true }, + }, + }, + files: { type: 'file[]', description: 'The uploaded attachment file' }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 28318b68ef0..3629be229e3 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -501,6 +501,46 @@ import { clickhouseTruncateTableTool, clickhouseUpdateTool, } from '@/tools/clickhouse' +import { + clickupAddTagToTaskTool, + clickupCreateChecklistItemTool, + clickupCreateChecklistTool, + clickupCreateCommentTool, + clickupCreateFolderTool, + clickupCreateListTool, + clickupCreateTaskTool, + clickupCreateTimeEntryTool, + clickupDeleteChecklistItemTool, + clickupDeleteChecklistTool, + clickupDeleteCommentTool, + clickupDeleteTaskTool, + clickupDeleteTimeEntryTool, + clickupGetCommentsTool, + clickupGetCustomFieldsTool, + clickupGetFoldersTool, + clickupGetListMembersTool, + clickupGetListsTool, + clickupGetRunningTimerTool, + clickupGetSpacesTool, + clickupGetSpaceTagsTool, + clickupGetTaskMembersTool, + clickupGetTasksTool, + clickupGetTaskTool, + clickupGetTimeEntriesTool, + clickupGetWorkspacesTool, + clickupRemoveCustomFieldValueTool, + clickupRemoveTagFromTaskTool, + clickupSearchTasksTool, + clickupSetCustomFieldValueTool, + clickupStartTimerTool, + clickupStopTimerTool, + clickupUpdateChecklistItemTool, + clickupUpdateChecklistTool, + clickupUpdateCommentTool, + clickupUpdateTaskTool, + clickupUpdateTimeEntryTool, + clickupUploadAttachmentTool, +} from '@/tools/clickup' import { cloudflareCreateDnsRecordTool, cloudflareCreateZoneTool, @@ -7536,6 +7576,44 @@ export const tools: Record = { clerk_get_jwt_template: clerkGetJwtTemplateTool, clerk_create_actor_token: clerkCreateActorTokenTool, clerk_revoke_actor_token: clerkRevokeActorTokenTool, + clickup_create_task: clickupCreateTaskTool, + clickup_get_task: clickupGetTaskTool, + clickup_update_task: clickupUpdateTaskTool, + clickup_delete_task: clickupDeleteTaskTool, + clickup_get_tasks: clickupGetTasksTool, + clickup_search_tasks: clickupSearchTasksTool, + clickup_create_comment: clickupCreateCommentTool, + clickup_get_comments: clickupGetCommentsTool, + clickup_update_comment: clickupUpdateCommentTool, + clickup_delete_comment: clickupDeleteCommentTool, + clickup_upload_attachment: clickupUploadAttachmentTool, + clickup_add_tag_to_task: clickupAddTagToTaskTool, + clickup_remove_tag_from_task: clickupRemoveTagFromTaskTool, + clickup_get_space_tags: clickupGetSpaceTagsTool, + clickup_get_task_members: clickupGetTaskMembersTool, + clickup_get_list_members: clickupGetListMembersTool, + clickup_get_custom_fields: clickupGetCustomFieldsTool, + clickup_get_workspaces: clickupGetWorkspacesTool, + clickup_get_spaces: clickupGetSpacesTool, + clickup_get_folders: clickupGetFoldersTool, + clickup_get_lists: clickupGetListsTool, + clickup_create_folder: clickupCreateFolderTool, + clickup_create_list: clickupCreateListTool, + clickup_set_custom_field_value: clickupSetCustomFieldValueTool, + clickup_remove_custom_field_value: clickupRemoveCustomFieldValueTool, + clickup_create_checklist: clickupCreateChecklistTool, + clickup_update_checklist: clickupUpdateChecklistTool, + clickup_delete_checklist: clickupDeleteChecklistTool, + clickup_create_checklist_item: clickupCreateChecklistItemTool, + clickup_update_checklist_item: clickupUpdateChecklistItemTool, + clickup_delete_checklist_item: clickupDeleteChecklistItemTool, + clickup_get_time_entries: clickupGetTimeEntriesTool, + clickup_create_time_entry: clickupCreateTimeEntryTool, + clickup_update_time_entry: clickupUpdateTimeEntryTool, + clickup_delete_time_entry: clickupDeleteTimeEntryTool, + clickup_start_timer: clickupStartTimerTool, + clickup_stop_timer: clickupStopTimerTool, + clickup_get_running_timer: clickupGetRunningTimerTool, cloudflare_list_zones: cloudflareListZonesTool, cloudflare_get_zone: cloudflareGetZoneTool, cloudflare_create_zone: cloudflareCreateZoneTool, diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 077e84e235a..2ae646cd891 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -40,6 +40,7 @@ const HANDWRITTEN_INTEGRATION_DOCS = new Set([ 'a2a', 'google-service-account', 'atlassian-service-account', + 'clickup-service-account', 'hubspot-setup', ])