Compare commits

..

8 Commits

Author SHA1 Message Date
Waleed
ff7b5b528c v0.6.4: subflows, docusign, ashby new tools, box, workday, billing bug fixes 2026-03-18 23:12:36 -07:00
Waleed
cef321bda2 feat(box): add Box and Box Sign integrations (#3660)
* feat(box): add Box and Box Sign integrations

Add complete Box integration with file management (upload, download, get info, list folders, create/delete folders, copy, search, update metadata) and Box Sign e-signature support (create/get/list/cancel/resend sign requests). Includes OAuth provider setup, internal upload API route following the Dropbox pattern, block configurations, icon, and generated docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): address PR review comments

- Fix docsLink for Box Sign: use underscore (box_sign) to match docs URL
- Move normalizeFileInput from tool() to params() in Box block config to match Dropbox pattern
- Throw error on invalid additionalSigners JSON instead of silently dropping signers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): remove unsupported reason param from cancel sign request

The Box Sign cancel endpoint (POST /sign_requests/{id}/cancel) does not
accept a request body per the API specification. Remove the misleading
reason parameter from the tool, types, block config, and docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): use canonical param ID for file normalization in params()

The params function must reference canonical IDs (params.file), not raw
subBlock IDs (uploadFile/fileRef) which are deleted after canonical
transformation. Matches the Dropbox block pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): use generic output descriptions for shared file properties

Rename "Uploaded file ID/name" to "File ID/name" in
UPLOAD_FILE_OUTPUT_PROPERTIES since the constant is shared by both
upload and copy operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): rename items output to entries for list_folder_items

Rename the output field from "items" to "entries" to match Box API
naming and avoid collision with JSON schema "items" keyword that
prevented the docs generator from rendering the nested array structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): filter empty file IDs from sourceFileIds input

Add .filter(Boolean) after splitting sourceFileIds to prevent empty
strings from trailing/double commas being sent as invalid file IDs
to the Box Sign API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(box): merge Box Sign into single Box block

Combine Box and Box Sign into one unified block with all 15 operations
accessible via a single dropdown, removing the separate box_sign block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): filter empty strings from tags array in update_file

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(docs): apply lint formatting to icon-mapping and meta.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(box): format chained method calls per linter rules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(box,docusign): set block bgColor to white and regenerate docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(docs): apply lint formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): populate OAuth scopes for Box since token response omits them

Box's OAuth2 token endpoint does not return a scope field in the
response, so Better Auth stores nothing in the DB. This causes the
credential selector to always show "Additional permissions required".
Fix by populating the scope from the requested scopes in the
account.create.before hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): add sign_requests.readwrite scope for Box Sign operations

Box Sign API requires the sign_requests.readwrite scope in addition
to root_readwrite. Without it, sign requests fail with "The request
requires higher privileges than provided by the access token."

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update docs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:06:08 -07:00
Vikhyath Mondreti
1809b3801b improvement(billing): immediately charge for billing upgrades (#3664)
* improvement(billing): immediately charge for billing upgrades

* block on payment failures even for upgrades

* address bugbot comments
2026-03-18 22:47:31 -07:00
Vikhyath Mondreti
bc111a6d5c feat(workday): block + tools (#3663)
* checkpoint workday block

* add icon svg

* fix workday to use soap api

* fix SOAP API

* address comments

* fix

* more type fixes

* address more comments

* fix files

* fix file editor useEffect

* fix build issue

* fix typing

* fix test
2026-03-18 22:26:10 -07:00
Waleed
12908c14be feat(ashby): add 15 new tools and fix existing tool accuracy (#3662)
* feat(ashby): add 15 new tools and fix existing tool accuracy

* fix(ashby): fix response field mappings for changeStage and createNote

* fix(ashby): fix websiteUrl field name in candidate.update request

* fix(ashby): revert body field names to candidateId and jobId for info endpoints

* fix(ashby): add subblock ID migrations for removed emailType and phoneType

* fix(ashby): map removed emailType/phoneType to dummy keys to avoid data corruption
2026-03-18 22:12:16 -07:00
Waleed
638063cac1 feat(docusign): add docusign integration (#3661)
* feat(docusign): add DocuSign e-signature integration

* fix(docusign): add base_uri null check and move file normalization to params

* fix(docusign): use canonical param documentFile instead of raw subBlock IDs

* fix(docusign): validate document file is present before sending envelope

* fix(docusign): rename tool files from kebab-case to snake_case for docs generation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:07:51 -07:00
Vikhyath Mondreti
5f7a980c5f fix(schedules): deployment bug (#3666)
* fix(schedules): deployment bug

* fix
2026-03-18 21:39:13 -07:00
Vikhyath Mondreti
a2c08e19a8 fix(subflows): subflow-child selection issues, subflow error logs (#3656)
* fix(subflows): subflow-child selection issues, subflow error logs

* address comments

* make selection context more reliable

* fix more multiselect issues

* fix shift selection ordering to work correctly

* fix more comments

* address more comments

* reuse helper
2026-03-18 19:08:14 -07:00
131 changed files with 10365 additions and 542 deletions

View File

@@ -1146,6 +1146,25 @@ export function DevinIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function DocuSignIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 1547 1549' xmlns='http://www.w3.org/2000/svg'>
<path
d='m1113.4 1114.9v395.6c0 20.8-16.7 37.6-37.5 37.6h-1038.4c-20.7 0-37.5-16.8-37.5-37.6v-1039c0-20.7 16.8-37.5 37.5-37.5h394.3v643.4c0 20.7 16.8 37.5 37.5 37.5z'
fill='#4c00ff'
/>
<path
d='m1546 557.1c0 332.4-193.9 557-432.6 557.8v-418.8c0-12-4.8-24-13.5-31.9l-217.1-217.4c-8.8-8.8-20-13.6-32-13.6h-418.2v-394.8c0-20.8 16.8-37.6 37.5-37.6h585.1c277.7-0.8 490.8 223 490.8 556.3z'
fill='#ff5252'
/>
<path
d='m1099.9 663.4c8.7 8.7 13.5 19.9 13.5 31.9v418.8h-643.3c-20.7 0-37.5-16.8-37.5-37.5v-643.4h418.2c12 0 24 4.8 32 13.6z'
fill='#000000'
/>
</svg>
)
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -1390,7 +1409,7 @@ export function AmplitudeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 49 49'>
<path
fill='#FFFFFF'
fill='currentColor'
d='M23.4,15.3c0.6,1.8,1.2,4.1,1.9,6.7c-2.6,0-5.3-0.1-7.8-0.1h-1.3c1.5-5.7,3.2-10.1,4.6-11.1 c0.1-0.1,0.2-0.1,0.4-0.1c0.2,0,0.3,0.1,0.5,0.3C21.9,11.5,22.5,12.7,23.4,15.3z M49,24.5C49,38,38,49,24.5,49S0,38,0,24.5 S11,0,24.5,0S49,11,49,24.5z M42.7,23.9c0-0.6-0.4-1.2-1-1.3l0,0l0,0l0,0c-0.1,0-0.1,0-0.2,0h-0.2c-4.1-0.3-8.4-0.4-12.4-0.5l0,0 C27,14.8,24.5,7.4,21.3,7.4c-3,0-5.8,4.9-8.2,14.5c-1.7,0-3.2,0-4.6-0.1c-0.1,0-0.2,0-0.2,0c-0.3,0-0.5,0-0.5,0 c-0.8,0.1-1.4,0.9-1.4,1.7c0,0.8,0.6,1.6,1.5,1.7l0,0h4.6c-0.4,1.9-0.8,3.8-1.1,5.6l-0.1,0.8l0,0c0,0.6,0.5,1.1,1.1,1.1 c0.4,0,0.8-0.2,1-0.5l0,0l2.2-7.1h10.7c0.8,3.1,1.7,6.3,2.8,9.3c0.6,1.6,2,5.4,4.4,5.4l0,0c3.6,0,5-5.8,5.9-9.6 c0.2-0.8,0.4-1.5,0.5-2.1l0.1-0.2l0,0c0-0.1,0-0.2,0-0.3c-0.1-0.2-0.2-0.3-0.4-0.4c-0.3-0.1-0.5,0.1-0.6,0.4l0,0l-0.1,0.2 c-0.3,0.8-0.6,1.6-0.8,2.3v0.1c-1.6,4.4-2.3,6.4-3.7,6.4l0,0l0,0l0,0c-1.8,0-3.5-7.3-4.1-10.1c-0.1-0.5-0.2-0.9-0.3-1.3h11.7 c0.2,0,0.4-0.1,0.6-0.1l0,0c0,0,0,0,0.1,0c0,0,0,0,0.1,0l0,0c0,0,0.1,0,0.1-0.1l0,0C42.5,24.6,42.7,24.3,42.7,23.9z'
/>
</svg>
@@ -4569,11 +4588,17 @@ export function ShopifyIcon(props: SVGProps<SVGSVGElement>) {
export function BoxCompanyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 41 22'>
<path
d='M39.7 19.2c.5.7.4 1.6-.2 2.1-.7.5-1.7.4-2.2-.2l-3.5-4.5-3.4 4.4c-.5.7-1.5.7-2.2.2-.7-.5-.8-1.4-.3-2.1l4-5.2-4-5.2c-.5-.7-.3-1.7.3-2.2.7-.5 1.7-.3 2.2.3l3.4 4.5L37.3 7c.5-.7 1.4-.8 2.2-.3.7.5.7 1.5.2 2.2L35.8 14l3.9 5.2zm-18.2-.6c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c-.1 2.6-2.2 4.6-4.7 4.6zm-13.8 0c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c0 2.6-2.1 4.6-4.7 4.6zM21.5 6.4c-2.9 0-5.5 1.6-6.8 4-1.3-2.4-3.9-4-6.9-4-1.8 0-3.4.6-4.7 1.5V1.5C3.1.7 2.4 0 1.6 0 .7 0 0 .7 0 1.5v12.6c.1 4.2 3.5 7.5 7.7 7.5 3 0 5.6-1.7 6.9-4.1 1.3 2.4 3.9 4.1 6.8 4.1 4.3 0 7.8-3.4 7.8-7.7.1-4.1-3.4-7.5-7.7-7.5z'
fill='currentColor'
/>
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
width='2500'
height='1379'
viewBox='0 0 444.893 245.414'
>
<g fill='#0075C9'>
<path d='M239.038 72.43c-33.081 0-61.806 18.6-76.322 45.904-14.516-27.305-43.24-45.902-76.32-45.902-19.443 0-37.385 6.424-51.821 17.266V16.925h-.008C34.365 7.547 26.713 0 17.286 0 7.858 0 .208 7.547.008 16.925H0v143.333h.036c.768 47.051 39.125 84.967 86.359 84.967 33.08 0 61.805-18.603 76.32-45.908 14.517 27.307 43.241 45.906 76.321 45.906 47.715 0 86.396-38.684 86.396-86.396.001-47.718-38.682-86.397-86.394-86.397zM86.395 210.648c-28.621 0-51.821-23.201-51.821-51.82 0-28.623 23.201-51.823 51.821-51.823 28.621 0 51.822 23.2 51.822 51.823 0 28.619-23.201 51.82-51.822 51.82zm152.643 0c-28.622 0-51.821-23.201-51.821-51.822 0-28.623 23.2-51.821 51.821-51.821 28.619 0 51.822 23.198 51.822 51.821-.001 28.621-23.203 51.822-51.822 51.822z' />
<path d='M441.651 218.033l-44.246-59.143 44.246-59.144-.008-.007c5.473-7.62 3.887-18.249-3.652-23.913-7.537-5.658-18.187-4.221-23.98 3.157l-.004-.002-38.188 51.047-38.188-51.047-.006.009c-5.793-7.385-16.441-8.822-23.981-3.16-7.539 5.664-9.125 16.293-3.649 23.911l-.008.005 44.245 59.144-44.245 59.143.008.005c-5.477 7.62-3.89 18.247 3.649 23.909 7.54 5.664 18.188 4.225 23.981-3.155l.006.007 38.188-51.049 38.188 51.049.004-.002c5.794 7.377 16.443 8.814 23.98 3.154 7.539-5.662 9.125-16.291 3.652-23.91l.008-.008z' />
</g>
</svg>
)
}

View File

@@ -16,6 +16,7 @@ import {
AsanaIcon,
AshbyIcon,
AttioIcon,
BoxCompanyIcon,
BrainIcon,
BrandfetchIcon,
BrowserUseIcon,
@@ -32,6 +33,7 @@ import {
DevinIcon,
DiscordIcon,
DocumentIcon,
DocuSignIcon,
DropboxIcon,
DsPyIcon,
DubIcon,
@@ -184,6 +186,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
asana: AsanaIcon,
ashby: AshbyIcon,
attio: AttioIcon,
box: BoxCompanyIcon,
brandfetch: BrandfetchIcon,
browser_use: BrowserUseIcon,
calcom: CalComIcon,
@@ -198,6 +201,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
datadog: DatadogIcon,
devin: DevinIcon,
discord: DiscordIcon,
docusign: DocuSignIcon,
dropbox: DropboxIcon,
dspy: DsPyIcon,
dub: DubIcon,

View File

@@ -0,0 +1,440 @@
---
title: Box
description: Manage files, folders, and e-signatures with Box
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="box"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[Box](https://www.box.com/) is a leading cloud content management and file sharing platform trusted by enterprises worldwide to securely store, manage, and collaborate on files. Box provides robust APIs for automating file operations and integrating with business workflows, including [Box Sign](https://www.box.com/esignature) for native e-signatures.
With the Box integration in Sim, you can:
- **Upload files**: Upload documents, images, and other files to any Box folder
- **Download files**: Retrieve file content from Box for processing in your workflows
- **Get file info**: Access detailed metadata including size, owner, timestamps, tags, and shared links
- **List folder contents**: Browse files and folders with sorting and pagination support
- **Create folders**: Organize your Box storage by creating new folders programmatically
- **Delete files and folders**: Remove content with optional recursive deletion for folders
- **Copy files**: Duplicate files across folders with optional renaming
- **Search**: Find files and folders by name, content, extension, or location
- **Update file metadata**: Rename, move, add descriptions, or tag files
- **Create sign requests**: Send documents for e-signature with one or more signers
- **Track signing status**: Monitor the progress of sign requests
- **List sign requests**: View all sign requests with marker-based pagination
- **Cancel sign requests**: Cancel pending sign requests that are no longer needed
- **Resend sign reminders**: Send reminder notifications to signers who haven't completed signing
These capabilities allow your Sim agents to automate Box operations directly within your workflows — from organizing documents and distributing content to processing uploaded files, managing e-signature workflows for offer letters and contracts, and maintaining structured cloud storage as part of your business processes.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Box into your workflow to manage files, folders, and e-signatures. Upload and download files, search content, create folders, send documents for e-signature, track signing status, and more.
## Tools
### `box_upload_file`
Upload a file to a Box folder
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `parentFolderId` | string | Yes | The ID of the folder to upload the file to \(use "0" for root\) |
| `file` | file | No | The file to upload \(UserFile object\) |
| `fileContent` | string | No | Legacy: base64 encoded file content |
| `fileName` | string | No | Optional filename override |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `size` | number | File size in bytes |
| `sha1` | string | SHA1 hash of file content |
| `createdAt` | string | Creation timestamp |
| `modifiedAt` | string | Last modified timestamp |
| `parentId` | string | Parent folder ID |
| `parentName` | string | Parent folder name |
### `box_download_file`
Download a file from Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to download |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | file | Downloaded file stored in execution files |
| `content` | string | Base64 encoded file content |
### `box_get_file_info`
Get detailed information about a file in Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to get information about |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `description` | string | File description |
| `size` | number | File size in bytes |
| `sha1` | string | SHA1 hash of file content |
| `createdAt` | string | Creation timestamp |
| `modifiedAt` | string | Last modified timestamp |
| `createdBy` | object | User who created the file |
| `modifiedBy` | object | User who last modified the file |
| `ownedBy` | object | User who owns the file |
| `parentId` | string | Parent folder ID |
| `parentName` | string | Parent folder name |
| `sharedLink` | json | Shared link details |
| `tags` | array | File tags |
| `commentCount` | number | Number of comments |
### `box_list_folder_items`
List files and folders in a Box folder
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `folderId` | string | Yes | The ID of the folder to list items from \(use "0" for root\) |
| `limit` | number | No | Maximum number of items to return per page |
| `offset` | number | No | The offset for pagination |
| `sort` | string | No | Sort field: id, name, date, or size |
| `direction` | string | No | Sort direction: ASC or DESC |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `entries` | array | List of items in the folder |
| ↳ `type` | string | Item type \(file, folder, web_link\) |
| ↳ `id` | string | Item ID |
| ↳ `name` | string | Item name |
| ↳ `size` | number | Item size in bytes |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `modifiedAt` | string | Last modified timestamp |
| `totalCount` | number | Total number of items in the folder |
| `offset` | number | Current pagination offset |
| `limit` | number | Current pagination limit |
### `box_create_folder`
Create a new folder in Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `name` | string | Yes | Name for the new folder |
| `parentFolderId` | string | Yes | The ID of the parent folder \(use "0" for root\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Folder ID |
| `name` | string | Folder name |
| `createdAt` | string | Creation timestamp |
| `modifiedAt` | string | Last modified timestamp |
| `parentId` | string | Parent folder ID |
| `parentName` | string | Parent folder name |
### `box_delete_file`
Delete a file from Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the file was successfully deleted |
| `message` | string | Success confirmation message |
### `box_delete_folder`
Delete a folder from Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `folderId` | string | Yes | The ID of the folder to delete |
| `recursive` | boolean | No | Delete folder and all its contents recursively |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the folder was successfully deleted |
| `message` | string | Success confirmation message |
### `box_copy_file`
Copy a file to another folder in Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to copy |
| `parentFolderId` | string | Yes | The ID of the destination folder |
| `name` | string | No | Optional new name for the copied file |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `size` | number | File size in bytes |
| `sha1` | string | SHA1 hash of file content |
| `createdAt` | string | Creation timestamp |
| `modifiedAt` | string | Last modified timestamp |
| `parentId` | string | Parent folder ID |
| `parentName` | string | Parent folder name |
### `box_search`
Search for files and folders in Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | The search query string |
| `limit` | number | No | Maximum number of results to return |
| `offset` | number | No | The offset for pagination |
| `ancestorFolderId` | string | No | Restrict search to a specific folder and its subfolders |
| `fileExtensions` | string | No | Comma-separated file extensions to filter by \(e.g., pdf,docx\) |
| `type` | string | No | Restrict to a specific content type: file, folder, or web_link |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | array | Search results |
| ↳ `type` | string | Item type \(file, folder, web_link\) |
| ↳ `id` | string | Item ID |
| ↳ `name` | string | Item name |
| ↳ `size` | number | Item size in bytes |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `modifiedAt` | string | Last modified timestamp |
| ↳ `parentId` | string | Parent folder ID |
| ↳ `parentName` | string | Parent folder name |
| `totalCount` | number | Total number of matching results |
### `box_update_file`
Update file info in Box (rename, move, change description, add tags)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to update |
| `name` | string | No | New name for the file |
| `description` | string | No | New description for the file \(max 256 characters\) |
| `parentFolderId` | string | No | Move the file to a different folder by specifying the folder ID |
| `tags` | string | No | Comma-separated tags to set on the file |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `description` | string | File description |
| `size` | number | File size in bytes |
| `sha1` | string | SHA1 hash of file content |
| `createdAt` | string | Creation timestamp |
| `modifiedAt` | string | Last modified timestamp |
| `createdBy` | object | User who created the file |
| `modifiedBy` | object | User who last modified the file |
| `ownedBy` | object | User who owns the file |
| `parentId` | string | Parent folder ID |
| `parentName` | string | Parent folder name |
| `sharedLink` | json | Shared link details |
| `tags` | array | File tags |
| `commentCount` | number | Number of comments |
### `box_sign_create_request`
Create a new Box Sign request to send documents for e-signature
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `sourceFileIds` | string | Yes | Comma-separated Box file IDs to send for signing |
| `signerEmail` | string | Yes | Primary signer email address |
| `signerRole` | string | No | Primary signer role: signer, approver, or final_copy_reader \(default: signer\) |
| `additionalSigners` | string | No | JSON array of additional signers, e.g. \[\{"email":"user@example.com","role":"signer"\}\] |
| `parentFolderId` | string | No | Box folder ID where signed documents will be stored \(default: user root\) |
| `emailSubject` | string | No | Custom subject line for the signing email |
| `emailMessage` | string | No | Custom message in the signing email body |
| `name` | string | No | Name for the sign request |
| `daysValid` | number | No | Number of days before the request expires \(0-730\) |
| `areRemindersEnabled` | boolean | No | Whether to send automatic signing reminders |
| `areTextSignaturesEnabled` | boolean | No | Whether to allow typed \(text\) signatures |
| `signatureColor` | string | No | Signature color: blue, black, or red |
| `redirectUrl` | string | No | URL to redirect signers to after signing |
| `declinedRedirectUrl` | string | No | URL to redirect signers to after declining |
| `isDocumentPreparationNeeded` | boolean | No | Whether document preparation is needed before sending |
| `externalId` | string | No | External system reference ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Sign request ID |
| `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
| `name` | string | Sign request name |
| `shortId` | string | Human-readable short ID |
| `signers` | array | List of signers |
| `sourceFiles` | array | Source files for signing |
| `emailSubject` | string | Custom email subject line |
| `emailMessage` | string | Custom email message body |
| `daysValid` | number | Number of days the request is valid |
| `createdAt` | string | Creation timestamp |
| `autoExpireAt` | string | Auto-expiration timestamp |
| `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
| `senderEmail` | string | Email of the sender |
### `box_sign_get_request`
Get the details and status of a Box Sign request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `signRequestId` | string | Yes | The ID of the sign request to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Sign request ID |
| `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
| `name` | string | Sign request name |
| `shortId` | string | Human-readable short ID |
| `signers` | array | List of signers |
| `sourceFiles` | array | Source files for signing |
| `emailSubject` | string | Custom email subject line |
| `emailMessage` | string | Custom email message body |
| `daysValid` | number | Number of days the request is valid |
| `createdAt` | string | Creation timestamp |
| `autoExpireAt` | string | Auto-expiration timestamp |
| `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
| `senderEmail` | string | Email of the sender |
### `box_sign_list_requests`
List all Box Sign requests
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | number | No | Maximum number of sign requests to return \(max 1000\) |
| `marker` | string | No | Pagination marker from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `signRequests` | array | List of sign requests |
| ↳ `id` | string | Sign request ID |
| ↳ `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
| ↳ `name` | string | Sign request name |
| ↳ `shortId` | string | Human-readable short ID |
| ↳ `signers` | array | List of signers |
| ↳ `sourceFiles` | array | Source files for signing |
| ↳ `emailSubject` | string | Custom email subject line |
| ↳ `emailMessage` | string | Custom email message body |
| ↳ `daysValid` | number | Number of days the request is valid |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `autoExpireAt` | string | Auto-expiration timestamp |
| ↳ `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
| ↳ `senderEmail` | string | Email of the sender |
| `count` | number | Number of sign requests returned in this page |
| `nextMarker` | string | Marker for next page of results |
### `box_sign_cancel_request`
Cancel a pending Box Sign request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `signRequestId` | string | Yes | The ID of the sign request to cancel |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Sign request ID |
| `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
| `name` | string | Sign request name |
| `shortId` | string | Human-readable short ID |
| `signers` | array | List of signers |
| `sourceFiles` | array | Source files for signing |
| `emailSubject` | string | Custom email subject line |
| `emailMessage` | string | Custom email message body |
| `daysValid` | number | Number of days the request is valid |
| `createdAt` | string | Creation timestamp |
| `autoExpireAt` | string | Auto-expiration timestamp |
| `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
| `senderEmail` | string | Email of the sender |
### `box_sign_resend_request`
Resend a Box Sign request to signers who have not yet signed
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `signRequestId` | string | Yes | The ID of the sign request to resend |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Success confirmation message |

View File

@@ -0,0 +1,230 @@
---
title: DocuSign
description: Send documents for e-signature via DocuSign
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="docusign"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[DocuSign](https://www.docusign.com) is the world's leading e-signature platform, enabling businesses to send, sign, and manage agreements digitally. With its powerful eSignature REST API, DocuSign supports the full document lifecycle from creation through completion.
With the DocuSign integration in Sim, you can:
- **Send envelopes**: Create and send documents for e-signature with custom recipients and signing tabs
- **Use templates**: Send envelopes from pre-configured DocuSign templates with role assignments
- **Track status**: Get envelope details including signing progress, timestamps, and recipient status
- **List envelopes**: Search and filter envelopes by date range, status, and text
- **Download documents**: Retrieve signed documents as base64-encoded files
- **Manage recipients**: View signer and CC recipient details and signing status
- **Void envelopes**: Cancel in-progress envelopes with a reason
In Sim, the DocuSign integration enables your agents to automate document workflows end-to-end. Agents can generate agreements, send them for signature, monitor completion, and retrieve signed copies—powering contract management, HR onboarding, sales closings, and compliance processes.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Create and send envelopes for e-signature, use templates, check signing status, download signed documents, and manage recipients with DocuSign.
## Tools
### `docusign_send_envelope`
Create and send a DocuSign envelope with a document for e-signature
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `emailSubject` | string | Yes | Email subject for the envelope |
| `emailBody` | string | No | Email body message |
| `signerEmail` | string | Yes | Email address of the signer |
| `signerName` | string | Yes | Full name of the signer |
| `ccEmail` | string | No | Email address of carbon copy recipient |
| `ccName` | string | No | Full name of carbon copy recipient |
| `file` | file | No | Document file to send for signature |
| `status` | string | No | Envelope status: "sent" to send immediately, "created" for draft \(default: "sent"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `envelopeId` | string | Created envelope ID |
| `status` | string | Envelope status |
| `statusDateTime` | string | Status change datetime |
| `uri` | string | Envelope URI |
### `docusign_create_from_template`
Create and send a DocuSign envelope using a pre-built template
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `templateId` | string | Yes | DocuSign template ID to use |
| `emailSubject` | string | No | Override email subject \(uses template default if not set\) |
| `emailBody` | string | No | Override email body message |
| `templateRoles` | string | Yes | JSON array of template roles, e.g. \[\{"roleName":"Signer","name":"John","email":"john@example.com"\}\] |
| `status` | string | No | Envelope status: "sent" to send immediately, "created" for draft \(default: "sent"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `envelopeId` | string | Created envelope ID |
| `status` | string | Envelope status |
| `statusDateTime` | string | Status change datetime |
| `uri` | string | Envelope URI |
### `docusign_get_envelope`
Get the details and status of a DocuSign envelope
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `envelopeId` | string | Yes | The envelope ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `envelopeId` | string | Envelope ID |
| `status` | string | Envelope status \(created, sent, delivered, completed, declined, voided\) |
| `emailSubject` | string | Email subject line |
| `sentDateTime` | string | When the envelope was sent |
| `completedDateTime` | string | When all recipients completed signing |
| `createdDateTime` | string | When the envelope was created |
| `statusChangedDateTime` | string | When the status last changed |
| `voidedReason` | string | Reason the envelope was voided |
| `signerCount` | number | Number of signers |
| `documentCount` | number | Number of documents |
### `docusign_list_envelopes`
List envelopes from your DocuSign account with optional filters
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fromDate` | string | No | Start date filter \(ISO 8601\). Defaults to 30 days ago |
| `toDate` | string | No | End date filter \(ISO 8601\) |
| `envelopeStatus` | string | No | Filter by status: created, sent, delivered, completed, declined, voided |
| `searchText` | string | No | Search text to filter envelopes |
| `count` | string | No | Maximum number of envelopes to return \(default: 25\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `envelopes` | array | Array of DocuSign envelopes |
| ↳ `envelopeId` | string | Unique envelope identifier |
| ↳ `status` | string | Envelope status \(created, sent, delivered, completed, declined, voided\) |
| ↳ `emailSubject` | string | Email subject line |
| ↳ `sentDateTime` | string | ISO 8601 datetime when envelope was sent |
| ↳ `completedDateTime` | string | ISO 8601 datetime when envelope was completed |
| ↳ `createdDateTime` | string | ISO 8601 datetime when envelope was created |
| ↳ `statusChangedDateTime` | string | ISO 8601 datetime of last status change |
| `totalSetSize` | number | Total number of matching envelopes |
| `resultSetSize` | number | Number of envelopes returned in this response |
### `docusign_void_envelope`
Void (cancel) a sent DocuSign envelope that has not yet been completed
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `envelopeId` | string | Yes | The envelope ID to void |
| `voidedReason` | string | Yes | Reason for voiding the envelope |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `envelopeId` | string | Voided envelope ID |
| `status` | string | Envelope status \(voided\) |
### `docusign_download_document`
Download a signed document from a completed DocuSign envelope
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `envelopeId` | string | Yes | The envelope ID containing the document |
| `documentId` | string | No | Specific document ID to download, or "combined" for all documents merged \(default: "combined"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `base64Content` | string | Base64-encoded document content |
| `mimeType` | string | MIME type of the document |
| `fileName` | string | Original file name |
### `docusign_list_templates`
List available templates in your DocuSign account
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `searchText` | string | No | Search text to filter templates by name |
| `count` | string | No | Maximum number of templates to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `templates` | array | Array of DocuSign templates |
| ↳ `templateId` | string | Template identifier |
| ↳ `name` | string | Template name |
| ↳ `description` | string | Template description |
| ↳ `shared` | boolean | Whether template is shared |
| ↳ `created` | string | ISO 8601 creation date |
| ↳ `lastModified` | string | ISO 8601 last modified date |
| `totalSetSize` | number | Total number of matching templates |
| `resultSetSize` | number | Number of templates returned in this response |
### `docusign_list_recipients`
Get the recipient status details for a DocuSign envelope
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `envelopeId` | string | Yes | The envelope ID to get recipients for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `signers` | array | Array of DocuSign recipients |
| ↳ `recipientId` | string | Recipient identifier |
| ↳ `name` | string | Recipient name |
| ↳ `email` | string | Recipient email address |
| ↳ `status` | string | Recipient signing status \(sent, delivered, completed, declined\) |
| ↳ `signedDateTime` | string | ISO 8601 datetime when recipient signed |
| ↳ `deliveredDateTime` | string | ISO 8601 datetime when delivered to recipient |
| `carbonCopies` | array | Array of carbon copy recipients |
| ↳ `recipientId` | string | Recipient ID |
| ↳ `name` | string | Recipient name |
| ↳ `email` | string | Recipient email |
| ↳ `status` | string | Recipient status |

View File

@@ -53,6 +53,9 @@ Extract structured content from web pages with comprehensive metadata support. C
| `url` | string | Yes | The URL to scrape content from \(e.g., "https://example.com/page"\) |
| `scrapeOptions` | json | No | Options for content scraping |
| `apiKey` | string | Yes | Firecrawl API key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -86,6 +89,9 @@ Search for information on the web using Firecrawl
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | The search query to use |
| `apiKey` | string | Yes | Firecrawl API key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -123,6 +129,9 @@ Crawl entire websites and extract structured content from all accessible pages
| `includePaths` | json | No | URL paths to include in crawling \(e.g., \["/docs/*", "/api/*"\]\). Only these paths will be crawled |
| `onlyMainContent` | boolean | No | Extract only main content from pages |
| `apiKey` | string | Yes | Firecrawl API Key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -142,7 +151,6 @@ Crawl entire websites and extract structured content from all accessible pages
| ↳ `statusCode` | number | HTTP status code |
| ↳ `ogLocaleAlternate` | array | Alternate locale versions |
| `total` | number | Total number of pages found during crawl |
| `creditsUsed` | number | Number of credits consumed by the crawl operation |
### `firecrawl_map`
@@ -161,6 +169,9 @@ Get a complete list of URLs from any website quickly and reliably. Useful for di
| `timeout` | number | No | Request timeout in milliseconds |
| `location` | json | No | Geographic context for proxying \(country, languages\) |
| `apiKey` | string | Yes | Firecrawl API key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -187,6 +198,9 @@ Extract structured data from entire webpages using natural language prompts and
| `ignoreInvalidURLs` | boolean | No | Skip invalid URLs in the array \(default: true\) |
| `scrapeOptions` | json | No | Advanced scraping configuration options |
| `apiKey` | string | Yes | Firecrawl API key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -217,7 +231,6 @@ Autonomous web data extraction agent. Searches and gathers information based on
| `success` | boolean | Whether the agent operation was successful |
| `status` | string | Current status of the agent job \(processing, completed, failed\) |
| `data` | object | Extracted data from the agent |
| `creditsUsed` | number | Number of credits consumed by this agent task |
| `expiresAt` | string | Timestamp when the results expire \(24 hours\) |
| `sources` | object | Array of source URLs used by the agent |

View File

@@ -46,6 +46,8 @@ Search for books using the Google Books API
| `startIndex` | number | No | Index of the first result to return \(for pagination\) |
| `maxResults` | number | No | Maximum number of results to return \(1-40\) |
| `langRestrict` | string | No | Restrict results to a specific language \(ISO 639-1 code\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -82,6 +84,8 @@ Get detailed information about a specific book volume
| `apiKey` | string | Yes | Google Books API key |
| `volumeId` | string | Yes | The ID of the volume to retrieve |
| `projection` | string | No | Projection level \(full, lite\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -50,6 +50,8 @@ Get current air quality data for a location
| `lat` | number | Yes | Latitude coordinate |
| `lng` | number | Yes | Longitude coordinate |
| `languageCode` | string | No | Language code for the response \(e.g., "en", "es"\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -91,6 +93,8 @@ Get directions and route information between two locations
| `waypoints` | json | No | Array of intermediate waypoints |
| `units` | string | No | Unit system: metric or imperial |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -135,6 +139,8 @@ Calculate travel distance and time between multiple origins and destinations
| `avoid` | string | No | Features to avoid: tolls, highways, or ferries |
| `units` | string | No | Unit system: metric or imperial |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -163,6 +169,8 @@ Get elevation data for a location
| `apiKey` | string | Yes | Google Maps API key |
| `lat` | number | Yes | Latitude coordinate |
| `lng` | number | Yes | Longitude coordinate |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -185,6 +193,8 @@ Convert an address into geographic coordinates (latitude and longitude)
| `address` | string | Yes | The address to geocode |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `region` | string | No | Region bias as a ccTLD code \(e.g., us, uk\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -217,6 +227,8 @@ Geolocate a device using WiFi access points, cell towers, or IP address
| `considerIp` | boolean | No | Whether to use IP address for geolocation \(default: true\) |
| `cellTowers` | array | No | Array of cell tower objects with cellId, locationAreaCode, mobileCountryCode, mobileNetworkCode |
| `wifiAccessPoints` | array | No | Array of WiFi access point objects with macAddress \(required\), signalStrength, etc. |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -238,6 +250,8 @@ Get detailed information about a specific place
| `placeId` | string | Yes | Google Place ID |
| `fields` | string | No | Comma-separated list of fields to return |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -290,6 +304,8 @@ Search for places using a text query
| `type` | string | No | Place type filter \(e.g., restaurant, cafe, hotel\) |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `region` | string | No | Region bias as a ccTLD code \(e.g., us, uk\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -322,6 +338,8 @@ Convert geographic coordinates (latitude and longitude) into a human-readable ad
| `lat` | number | Yes | Latitude coordinate |
| `lng` | number | Yes | Longitude coordinate |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -346,6 +364,8 @@ Snap GPS coordinates to the nearest road segment
| `apiKey` | string | Yes | Google Maps API key with Roads API enabled |
| `path` | string | Yes | Pipe-separated list of lat,lng coordinates \(e.g., "60.170880,24.942795\|60.170879,24.942796"\) |
| `interpolate` | boolean | No | Whether to interpolate additional points along the road |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -399,6 +419,8 @@ Get timezone information for a location
| `lng` | number | Yes | Longitude coordinate |
| `timestamp` | number | No | Unix timestamp to determine DST offset \(defaults to current time\) |
| `language` | string | No | Language code for timezone name \(e.g., en, es, fr\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -424,6 +446,8 @@ Validate and standardize a postal address
| `regionCode` | string | No | ISO 3166-1 alpha-2 country code \(e.g., "US", "CA"\) |
| `locality` | string | No | City or locality name |
| `enableUspsCass` | boolean | No | Enable USPS CASS validation for US addresses |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -55,6 +55,8 @@ Analyze a webpage for performance, accessibility, SEO, and best practices using
| `category` | string | No | Lighthouse categories to analyze \(comma-separated\): performance, accessibility, best-practices, seo |
| `strategy` | string | No | Analysis strategy: desktop or mobile |
| `locale` | string | No | Locale for results \(e.g., en, fr, de\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -43,6 +43,9 @@ Translate text between languages using the Google Cloud Translation API. Support
| `target` | string | Yes | Target language code \(e.g., "es", "fr", "de", "ja"\) |
| `source` | string | No | Source language code. If omitted, the API will auto-detect the source language. |
| `format` | string | No | Format of the text: "text" for plain text, "html" for HTML content |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -61,6 +64,9 @@ Detect the language of text using the Google Cloud Translation API.
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled |
| `text` | string | Yes | The text to detect the language of |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -138,6 +138,26 @@ Get the full transcript of a recording
| ↳ `end` | number | End timestamp in ms |
| ↳ `text` | string | Transcript text |
### `grain_list_views`
List available Grain views for webhook subscriptions
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
| `typeFilter` | string | No | Optional view type filter: recordings, highlights, or stories |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `views` | array | Array of Grain views |
| ↳ `id` | string | View UUID |
| ↳ `name` | string | View name |
| ↳ `type` | string | View type: recordings, highlights, or stories |
### `grain_list_teams`
List all teams in the workspace
@@ -185,15 +205,9 @@ Create a webhook to receive recording events
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
| `hookUrl` | string | Yes | Webhook endpoint URL \(e.g., "https://example.com/webhooks/grain"\) |
| `hookType` | string | Yes | Type of webhook: "recording_added" or "upload_status" |
| `filterBeforeDatetime` | string | No | Filter: recordings before this ISO8601 date \(e.g., "2024-01-15T00:00:00Z"\) |
| `filterAfterDatetime` | string | No | Filter: recordings after this ISO8601 date \(e.g., "2024-01-01T00:00:00Z"\) |
| `filterParticipantScope` | string | No | Filter: "internal" or "external" |
| `filterTeamId` | string | No | Filter: specific team UUID \(e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"\) |
| `filterMeetingTypeId` | string | No | Filter: specific meeting type UUID \(e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"\) |
| `includeHighlights` | boolean | No | Include highlights in webhook payload |
| `includeParticipants` | boolean | No | Include participants in webhook payload |
| `includeAiSummary` | boolean | No | Include AI summary in webhook payload |
| `viewId` | string | Yes | Grain view ID from GET /_/public-api/views |
| `actions` | array | No | Optional list of actions to subscribe to: added, updated, removed |
| `items` | string | No | No description |
#### Output
@@ -202,9 +216,8 @@ Create a webhook to receive recording events
| `id` | string | Hook UUID |
| `enabled` | boolean | Whether hook is active |
| `hook_url` | string | The webhook URL |
| `hook_type` | string | Type of hook: recording_added or upload_status |
| `filter` | object | Applied filters |
| `include` | object | Included fields |
| `view_id` | string | Grain view ID for the webhook |
| `actions` | array | Configured actions for the webhook |
| `inserted_at` | string | ISO8601 creation timestamp |
### `grain_list_hooks`
@@ -225,9 +238,8 @@ List all webhooks for the account
| ↳ `id` | string | Hook UUID |
| ↳ `enabled` | boolean | Whether hook is active |
| ↳ `hook_url` | string | Webhook URL |
| ↳ `hook_type` | string | Type: recording_added or upload_status |
| ↳ `filter` | object | Applied filters |
| ↳ `include` | object | Included fields |
| ↳ `view_id` | string | Grain view ID |
| ↳ `actions` | array | Configured actions |
| ↳ `inserted_at` | string | Creation timestamp |
### `grain_delete_hook`

View File

@@ -64,6 +64,7 @@ Extract and process web content into clean, LLM-friendly text using Jina AI Read
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | The extracted content from the URL, processed into clean, LLM-friendly text |
| `tokensUsed` | number | Number of Jina tokens consumed by this request |
### `jina_search`
@@ -97,5 +98,6 @@ Search the web and return top 5 results with LLM-friendly content. Each result i
| ↳ `content` | string | LLM-friendly extracted content |
| ↳ `usage` | object | Token usage information |
| ↳ `tokens` | number | Number of tokens consumed by this request |
| `tokensUsed` | number | Number of Jina tokens consumed by this request |

View File

@@ -122,6 +122,37 @@ Create a new document in a knowledge base
| `message` | string | Success or error message describing the operation result |
| `documentId` | string | ID of the created document |
### `knowledge_upsert_document`
Create or update a document in a knowledge base. If a document with the given ID or filename already exists, it will be replaced with the new content.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
| `documentId` | string | No | Optional ID of an existing document to update. If not provided, lookup is done by filename. |
| `name` | string | Yes | Name of the document |
| `content` | string | Yes | Content of the document |
| `documentTags` | json | No | Document tags |
| `documentTags` | string | No | No description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | object | Information about the upserted document |
| ↳ `documentId` | string | Document ID |
| ↳ `documentName` | string | Document name |
| ↳ `type` | string | Document type |
| ↳ `enabled` | boolean | Whether the document is enabled |
| ↳ `isUpdate` | boolean | Whether an existing document was replaced |
| ↳ `previousDocumentId` | string | ID of the document that was replaced, if any |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| `message` | string | Success or error message describing the operation result |
| `documentId` | string | ID of the upserted document |
### `knowledge_list_tags`
List all tag definitions for a knowledge base

View File

@@ -51,6 +51,9 @@ Search the web for information using Linkup
| `includeDomains` | string | No | Comma-separated list of domain names to restrict search results to |
| `includeInlineCitations` | boolean | No | Add inline citations to answers \(only applies when outputType is "sourcedAnswer"\) |
| `includeSources` | boolean | No | Include sources in response |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -13,6 +13,7 @@
"asana",
"ashby",
"attio",
"box",
"brandfetch",
"browser_use",
"calcom",
@@ -27,6 +28,7 @@
"datadog",
"devin",
"discord",
"docusign",
"dropbox",
"dspy",
"dub",

View File

@@ -49,6 +49,9 @@ Generate completions using Perplexity AI chat models
| `max_tokens` | number | No | Maximum number of tokens to generate \(e.g., 1024, 2048, 4096\) |
| `temperature` | number | No | Sampling temperature between 0 and 1 \(e.g., 0.0 for deterministic, 0.7 for creative\) |
| `apiKey` | string | Yes | Perplexity API key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -78,6 +81,8 @@ Get ranked search results from Perplexity
| `search_after_date` | string | No | Include only content published after this date \(format: MM/DD/YYYY\) |
| `search_before_date` | string | No | Include only content published before this date \(format: MM/DD/YYYY\) |
| `apiKey` | string | Yes | Perplexity API key |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -47,6 +47,9 @@ A powerful web search tool that provides access to Google search results through
| `hl` | string | No | Language code for search results \(e.g., "en", "es", "de", "fr"\) |
| `type` | string | No | Type of search to perform \(e.g., "search", "news", "images", "videos", "places", "shopping"\) |
| `apiKey` | string | Yes | Serper API Key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -142,7 +142,7 @@ export async function POST(request: NextRequest) {
quantity: currentQuantity,
},
],
proration_behavior: 'create_prorations',
proration_behavior: 'always_invoice',
})
}

View File

@@ -102,7 +102,7 @@ async function handleLocalFile(filename: string, userId: string): Promise<NextRe
throw new FileNotFoundError(`File not found: ${filename}`)
}
const filePath = findLocalFile(filename)
const filePath = await findLocalFile(filename)
if (!filePath) {
throw new FileNotFoundError(`File not found: ${filename}`)
@@ -228,7 +228,7 @@ async function handleCloudProxyPublic(
async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
try {
const filePath = findLocalFile(filename)
const filePath = await findLocalFile(filename)
if (!filePath) {
throw new FileNotFoundError(`File not found: ${filename}`)

View File

@@ -75,7 +75,7 @@ export async function POST(request: NextRequest) {
const uploadResults = []
for (const file of files) {
const originalName = file.name || 'untitled'
const originalName = file.name || 'untitled.md'
if (!validateFileExtension(originalName)) {
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'

View File

@@ -331,7 +331,7 @@ describe('extractFilename', () => {
describe('findLocalFile - Path Traversal Security Tests', () => {
describe('path traversal attack prevention', () => {
it.concurrent('should reject classic path traversal attacks', () => {
it.concurrent('should reject classic path traversal attacks', async () => {
const maliciousInputs = [
'../../../etc/passwd',
'..\\..\\..\\windows\\system32\\config\\sam',
@@ -340,35 +340,35 @@ describe('findLocalFile - Path Traversal Security Tests', () => {
'..\\config.ini',
]
maliciousInputs.forEach((input) => {
const result = findLocalFile(input)
for (const input of maliciousInputs) {
const result = await findLocalFile(input)
expect(result).toBeNull()
})
}
})
it.concurrent('should reject encoded path traversal attempts', () => {
it.concurrent('should reject encoded path traversal attempts', async () => {
const encodedInputs = [
'%2e%2e%2f%2e%2e%2f%65%74%63%2f%70%61%73%73%77%64', // ../../../etc/passwd
'..%2f..%2fetc%2fpasswd',
'..%5c..%5cconfig.ini',
]
encodedInputs.forEach((input) => {
const result = findLocalFile(input)
for (const input of encodedInputs) {
const result = await findLocalFile(input)
expect(result).toBeNull()
})
}
})
it.concurrent('should reject mixed path separators', () => {
it.concurrent('should reject mixed path separators', async () => {
const mixedInputs = ['../..\\config.txt', '..\\../secret.ini', '/..\\..\\system32']
mixedInputs.forEach((input) => {
const result = findLocalFile(input)
for (const input of mixedInputs) {
const result = await findLocalFile(input)
expect(result).toBeNull()
})
}
})
it.concurrent('should reject filenames with dangerous characters', () => {
it.concurrent('should reject filenames with dangerous characters', async () => {
const dangerousInputs = [
'file:with:colons.txt',
'file|with|pipes.txt',
@@ -376,43 +376,45 @@ describe('findLocalFile - Path Traversal Security Tests', () => {
'file*with*asterisks.txt',
]
dangerousInputs.forEach((input) => {
const result = findLocalFile(input)
for (const input of dangerousInputs) {
const result = await findLocalFile(input)
expect(result).toBeNull()
})
}
})
it.concurrent('should reject null and empty inputs', () => {
expect(findLocalFile('')).toBeNull()
expect(findLocalFile(' ')).toBeNull()
expect(findLocalFile('\t\n')).toBeNull()
it.concurrent('should reject null and empty inputs', async () => {
expect(await findLocalFile('')).toBeNull()
expect(await findLocalFile(' ')).toBeNull()
expect(await findLocalFile('\t\n')).toBeNull()
})
it.concurrent('should reject filenames that become empty after sanitization', () => {
it.concurrent('should reject filenames that become empty after sanitization', async () => {
const emptyAfterSanitization = ['../..', '..\\..\\', '////', '....', '..']
emptyAfterSanitization.forEach((input) => {
const result = findLocalFile(input)
for (const input of emptyAfterSanitization) {
const result = await findLocalFile(input)
expect(result).toBeNull()
})
}
})
})
describe('security validation passes for legitimate files', () => {
it.concurrent('should accept properly formatted filenames without throwing errors', () => {
const legitimateInputs = [
'document.pdf',
'image.png',
'data.csv',
'report-2024.doc',
'file_with_underscores.txt',
'file-with-dashes.json',
]
it.concurrent(
'should accept properly formatted filenames without throwing errors',
async () => {
const legitimateInputs = [
'document.pdf',
'image.png',
'data.csv',
'report-2024.doc',
'file_with_underscores.txt',
'file-with-dashes.json',
]
legitimateInputs.forEach((input) => {
// Should not throw security errors for legitimate filenames
expect(() => findLocalFile(input)).not.toThrow()
})
})
for (const input of legitimateInputs) {
await expect(findLocalFile(input)).resolves.toBeDefined()
}
}
)
})
})

View File

@@ -1,8 +1,5 @@
import { existsSync } from 'fs'
import path from 'path'
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { UPLOAD_DIR } from '@/lib/uploads/config'
import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
const logger = createLogger('FilesUtils')
@@ -123,76 +120,29 @@ export function extractFilename(path: string): string {
return filename
}
function sanitizeFilename(filename: string): string {
if (!filename || typeof filename !== 'string') {
throw new Error('Invalid filename provided')
}
if (!filename.includes('/')) {
throw new Error('File key must include a context prefix (e.g., kb/, workspace/, execution/)')
}
const segments = filename.split('/')
const sanitizedSegments = segments.map((segment) => {
if (segment === '..' || segment === '.') {
throw new Error('Path traversal detected')
}
const sanitized = segment.replace(/\.\./g, '').replace(/[\\]/g, '').replace(/^\./g, '').trim()
if (!sanitized) {
throw new Error('Invalid or empty path segment after sanitization')
}
if (
sanitized.includes(':') ||
sanitized.includes('|') ||
sanitized.includes('?') ||
sanitized.includes('*') ||
sanitized.includes('\x00') ||
/[\x00-\x1F\x7F]/.test(sanitized)
) {
throw new Error('Path segment contains invalid characters')
}
return sanitized
})
return sanitizedSegments.join(path.sep)
}
export function findLocalFile(filename: string): string | null {
export async function findLocalFile(filename: string): Promise<string | null> {
try {
const sanitizedFilename = sanitizeFileKey(filename)
// Reject if sanitized filename is empty or only contains path separators/dots
if (!sanitizedFilename || !sanitizedFilename.trim() || /^[/\\.\s]+$/.test(sanitizedFilename)) {
return null
}
const possiblePaths = [
path.join(UPLOAD_DIR, sanitizedFilename),
path.join(process.cwd(), 'uploads', sanitizedFilename),
]
const { existsSync } = await import('fs')
const path = await import('path')
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/core/setup.server')
for (const filePath of possiblePaths) {
const resolvedPath = path.resolve(filePath)
const allowedDirs = [path.resolve(UPLOAD_DIR), path.resolve(process.cwd(), 'uploads')]
const resolvedPath = path.join(UPLOAD_DIR_SERVER, sanitizedFilename)
// Must be within allowed directory but NOT the directory itself
const isWithinAllowedDir = allowedDirs.some(
(allowedDir) =>
resolvedPath.startsWith(allowedDir + path.sep) && resolvedPath !== allowedDir
)
if (
!resolvedPath.startsWith(UPLOAD_DIR_SERVER + path.sep) ||
resolvedPath === UPLOAD_DIR_SERVER
) {
return null
}
if (!isWithinAllowedDir) {
continue
}
if (existsSync(resolvedPath)) {
return resolvedPath
}
if (existsSync(resolvedPath)) {
return resolvedPath
}
return null

View File

@@ -161,7 +161,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
quantity: newSeatCount,
},
],
proration_behavior: 'create_prorations', // Stripe's default - charge/credit immediately
proration_behavior: 'always_invoice',
}
)
@@ -213,7 +213,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
oldSeats: currentSeats,
newSeats: newSeatCount,
updatedBy: session.user.id,
prorationBehavior: 'create_prorations',
prorationBehavior: 'always_invoice',
})
return NextResponse.json({

View File

@@ -0,0 +1,140 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
export const dynamic = 'force-dynamic'
const logger = createLogger('BoxUploadAPI')
const BoxUploadSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
parentFolderId: z.string().min(1, 'Parent folder ID is required'),
file: FileInputSchema.optional().nullable(),
fileContent: z.string().optional().nullable(),
fileName: z.string().optional().nullable(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized Box upload attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated Box upload request via ${authResult.authType}`)
const body = await request.json()
const validatedData = BoxUploadSchema.parse(body)
let fileBuffer: Buffer
let fileName: string
if (validatedData.file) {
const userFiles = processFilesToUserFiles(
[validatedData.file as RawFileInput],
requestId,
logger
)
if (userFiles.length === 0) {
return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 })
}
const userFile = userFiles[0]
logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`)
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
fileName = validatedData.fileName || userFile.name
} else if (validatedData.fileContent) {
logger.info(`[${requestId}] Using legacy base64 content input`)
fileBuffer = Buffer.from(validatedData.fileContent, 'base64')
fileName = validatedData.fileName || 'file'
} else {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}
logger.info(
`[${requestId}] Uploading to Box folder ${validatedData.parentFolderId}: ${fileName} (${fileBuffer.length} bytes)`
)
const attributes = JSON.stringify({
name: fileName,
parent: { id: validatedData.parentFolderId },
})
const formData = new FormData()
formData.append('attributes', attributes)
formData.append(
'file',
new Blob([new Uint8Array(fileBuffer)], { type: 'application/octet-stream' }),
fileName
)
const response = await fetch('https://upload.box.com/api/2.0/files/content', {
method: 'POST',
headers: {
Authorization: `Bearer ${validatedData.accessToken}`,
},
body: formData,
})
const data = await response.json()
if (!response.ok) {
const errorMessage = data.message || 'Failed to upload file'
logger.error(`[${requestId}] Box API error:`, { status: response.status, data })
return NextResponse.json({ success: false, error: errorMessage }, { status: response.status })
}
const file = data.entries?.[0]
if (!file) {
return NextResponse.json(
{ success: false, error: 'No file returned in upload response' },
{ status: 500 }
)
}
logger.info(`[${requestId}] File uploaded successfully: ${file.name} (ID: ${file.id})`)
return NextResponse.json({
success: true,
output: {
id: file.id ?? '',
name: file.name ?? '',
size: file.size ?? 0,
sha1: file.sha1 ?? null,
createdAt: file.created_at ?? null,
modifiedAt: file.modified_at ?? null,
parentId: file.parent?.id ?? null,
parentName: file.parent?.name ?? null,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Validation error:`, error.errors)
return NextResponse.json(
{ success: false, error: error.errors[0]?.message || 'Validation failed' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Unexpected error:`, error)
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,466 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
const logger = createLogger('DocuSignAPI')
interface DocuSignAccountInfo {
accountId: string
baseUri: string
}
/**
* Resolves the user's DocuSign account info from their access token
* by calling the DocuSign userinfo endpoint.
*/
async function resolveAccount(accessToken: string): Promise<DocuSignAccountInfo> {
const response = await fetch('https://account-d.docusign.com/oauth/userinfo', {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to resolve DocuSign account', {
status: response.status,
error: errorText,
})
throw new Error(`Failed to resolve DocuSign account: ${response.status}`)
}
const data = await response.json()
const accounts = data.accounts ?? []
const defaultAccount = accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0]
if (!defaultAccount) {
throw new Error('No DocuSign accounts found for this user')
}
const baseUri = defaultAccount.base_uri
if (!baseUri) {
throw new Error('DocuSign account is missing base_uri')
}
return {
accountId: defaultAccount.account_id,
baseUri,
}
}
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { accessToken, operation, ...params } = body
if (!accessToken) {
return NextResponse.json({ success: false, error: 'Access token is required' }, { status: 400 })
}
if (!operation) {
return NextResponse.json({ success: false, error: 'Operation is required' }, { status: 400 })
}
try {
const account = await resolveAccount(accessToken)
const apiBase = `${account.baseUri}/restapi/v2.1/accounts/${account.accountId}`
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
}
switch (operation) {
case 'send_envelope':
return await handleSendEnvelope(apiBase, headers, params)
case 'create_from_template':
return await handleCreateFromTemplate(apiBase, headers, params)
case 'get_envelope':
return await handleGetEnvelope(apiBase, headers, params)
case 'list_envelopes':
return await handleListEnvelopes(apiBase, headers, params)
case 'void_envelope':
return await handleVoidEnvelope(apiBase, headers, params)
case 'download_document':
return await handleDownloadDocument(apiBase, headers, params)
case 'list_templates':
return await handleListTemplates(apiBase, headers, params)
case 'list_recipients':
return await handleListRecipients(apiBase, headers, params)
default:
return NextResponse.json(
{ success: false, error: `Unknown operation: ${operation}` },
{ status: 400 }
)
}
} catch (error) {
logger.error('DocuSign API error', { operation, error })
const message = error instanceof Error ? error.message : 'Internal server error'
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}
async function handleSendEnvelope(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { signerEmail, signerName, emailSubject, emailBody, ccEmail, ccName, file, status } = params
if (!signerEmail || !signerName || !emailSubject) {
return NextResponse.json(
{ success: false, error: 'signerEmail, signerName, and emailSubject are required' },
{ status: 400 }
)
}
let documentBase64 = ''
let documentName = 'document.pdf'
if (file) {
try {
const parsed = FileInputSchema.parse(file)
const userFiles = processFilesToUserFiles([parsed as RawFileInput], 'docusign-send', logger)
if (userFiles.length > 0) {
const userFile = userFiles[0]
const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger)
documentBase64 = buffer.toString('base64')
documentName = userFile.name
}
} catch (fileError) {
logger.error('Failed to process file for DocuSign envelope', { fileError })
return NextResponse.json(
{ success: false, error: 'Failed to process uploaded file' },
{ status: 400 }
)
}
}
const envelopeBody: Record<string, unknown> = {
emailSubject,
status: (status as string) || 'sent',
recipients: {
signers: [
{
email: signerEmail,
name: signerName,
recipientId: '1',
routingOrder: '1',
tabs: {
signHereTabs: [
{
anchorString: '/sig1/',
anchorUnits: 'pixels',
anchorXOffset: '0',
anchorYOffset: '0',
},
],
dateSignedTabs: [
{
anchorString: '/date1/',
anchorUnits: 'pixels',
anchorXOffset: '0',
anchorYOffset: '0',
},
],
},
},
],
carbonCopies: ccEmail
? [
{
email: ccEmail,
name: ccName || (ccEmail as string),
recipientId: '2',
routingOrder: '2',
},
]
: [],
},
}
if (emailBody) {
envelopeBody.emailBlurb = emailBody
}
if (documentBase64) {
envelopeBody.documents = [
{
documentBase64,
name: documentName,
fileExtension: documentName.split('.').pop() || 'pdf',
documentId: '1',
},
]
} else if (((status as string) || 'sent') === 'sent') {
return NextResponse.json(
{ success: false, error: 'A document file is required to send an envelope' },
{ status: 400 }
)
}
const response = await fetch(`${apiBase}/envelopes`, {
method: 'POST',
headers,
body: JSON.stringify(envelopeBody),
})
const data = await response.json()
if (!response.ok) {
logger.error('DocuSign send envelope failed', { data, status: response.status })
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to send envelope' },
{ status: response.status }
)
}
return NextResponse.json(data)
}
async function handleCreateFromTemplate(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { templateId, emailSubject, emailBody, templateRoles, status } = params
if (!templateId) {
return NextResponse.json({ success: false, error: 'templateId is required' }, { status: 400 })
}
let parsedRoles: unknown[] = []
if (templateRoles) {
if (typeof templateRoles === 'string') {
try {
parsedRoles = JSON.parse(templateRoles)
} catch {
return NextResponse.json(
{ success: false, error: 'Invalid JSON for templateRoles' },
{ status: 400 }
)
}
} else if (Array.isArray(templateRoles)) {
parsedRoles = templateRoles
}
}
const envelopeBody: Record<string, unknown> = {
templateId,
status: (status as string) || 'sent',
templateRoles: parsedRoles,
}
if (emailSubject) envelopeBody.emailSubject = emailSubject
if (emailBody) envelopeBody.emailBlurb = emailBody
const response = await fetch(`${apiBase}/envelopes`, {
method: 'POST',
headers,
body: JSON.stringify(envelopeBody),
})
const data = await response.json()
if (!response.ok) {
logger.error('DocuSign create from template failed', { data, status: response.status })
return NextResponse.json(
{
success: false,
error: data.message || data.errorCode || 'Failed to create envelope from template',
},
{ status: response.status }
)
}
return NextResponse.json(data)
}
async function handleGetEnvelope(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { envelopeId } = params
if (!envelopeId) {
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
}
const response = await fetch(
`${apiBase}/envelopes/${(envelopeId as string).trim()}?include=recipients,documents`,
{ headers }
)
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to get envelope' },
{ status: response.status }
)
}
return NextResponse.json(data)
}
async function handleListEnvelopes(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const queryParams = new URLSearchParams()
const fromDate = params.fromDate as string | undefined
if (fromDate) {
queryParams.append('from_date', fromDate)
} else {
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
queryParams.append('from_date', thirtyDaysAgo.toISOString())
}
if (params.toDate) queryParams.append('to_date', params.toDate as string)
if (params.envelopeStatus) queryParams.append('status', params.envelopeStatus as string)
if (params.searchText) queryParams.append('search_text', params.searchText as string)
if (params.count) queryParams.append('count', params.count as string)
const response = await fetch(`${apiBase}/envelopes?${queryParams}`, { headers })
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to list envelopes' },
{ status: response.status }
)
}
return NextResponse.json(data)
}
async function handleVoidEnvelope(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { envelopeId, voidedReason } = params
if (!envelopeId) {
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
}
if (!voidedReason) {
return NextResponse.json({ success: false, error: 'voidedReason is required' }, { status: 400 })
}
const response = await fetch(`${apiBase}/envelopes/${(envelopeId as string).trim()}`, {
method: 'PUT',
headers,
body: JSON.stringify({ status: 'voided', voidedReason }),
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to void envelope' },
{ status: response.status }
)
}
return NextResponse.json({ envelopeId, status: 'voided' })
}
async function handleDownloadDocument(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { envelopeId, documentId } = params
if (!envelopeId) {
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
}
const docId = (documentId as string) || 'combined'
const response = await fetch(
`${apiBase}/envelopes/${(envelopeId as string).trim()}/documents/${docId}`,
{
headers: { Authorization: headers.Authorization },
}
)
if (!response.ok) {
let errorText = ''
try {
errorText = await response.text()
} catch {
// ignore
}
return NextResponse.json(
{ success: false, error: `Failed to download document: ${response.status} ${errorText}` },
{ status: response.status }
)
}
const contentType = response.headers.get('content-type') || 'application/pdf'
const contentDisposition = response.headers.get('content-disposition') || ''
let fileName = `document-${docId}.pdf`
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch) {
fileName = filenameMatch[1].replace(/['"]/g, '')
}
const buffer = Buffer.from(await response.arrayBuffer())
const base64Content = buffer.toString('base64')
return NextResponse.json({ base64Content, mimeType: contentType, fileName })
}
async function handleListTemplates(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const queryParams = new URLSearchParams()
if (params.searchText) queryParams.append('search_text', params.searchText as string)
if (params.count) queryParams.append('count', params.count as string)
const queryString = queryParams.toString()
const url = queryString ? `${apiBase}/templates?${queryString}` : `${apiBase}/templates`
const response = await fetch(url, { headers })
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to list templates' },
{ status: response.status }
)
}
return NextResponse.json(data)
}
async function handleListRecipients(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { envelopeId } = params
if (!envelopeId) {
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
}
const response = await fetch(`${apiBase}/envelopes/${(envelopeId as string).trim()}/recipients`, {
headers,
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to list recipients' },
{ status: response.status }
)
}
return NextResponse.json(data)
}

View File

@@ -0,0 +1,67 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayAssignOnboardingAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
onboardingPlanId: z.string().min(1),
actionEventId: z.string().min(1),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const [result] = await client.Put_Onboarding_Plan_AssignmentAsync({
Onboarding_Plan_Assignment_Data: {
Onboarding_Plan_Reference: wdRef('Onboarding_Plan_ID', data.onboardingPlanId),
Person_Reference: wdRef('WID', data.workerId),
Action_Event_Reference: wdRef('Background_Check_ID', data.actionEventId),
Assignment_Effective_Moment: new Date().toISOString(),
Active: true,
},
})
return NextResponse.json({
success: true,
output: {
assignmentId: extractRefId(result?.Onboarding_Plan_Assignment_Reference),
workerId: data.workerId,
planId: data.onboardingPlanId,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday assign onboarding failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,94 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayChangeJobAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
effectiveDate: z.string().min(1),
newPositionId: z.string().optional(),
newJobProfileId: z.string().optional(),
newLocationId: z.string().optional(),
newSupervisoryOrgId: z.string().optional(),
reason: z.string().min(1, 'Reason is required for job changes'),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const changeJobDetailData: Record<string, unknown> = {
Reason_Reference: wdRef('Change_Job_Subcategory_ID', data.reason),
}
if (data.newPositionId) {
changeJobDetailData.Position_Reference = wdRef('Position_ID', data.newPositionId)
}
if (data.newJobProfileId) {
changeJobDetailData.Job_Profile_Reference = wdRef('Job_Profile_ID', data.newJobProfileId)
}
if (data.newLocationId) {
changeJobDetailData.Location_Reference = wdRef('Location_ID', data.newLocationId)
}
if (data.newSupervisoryOrgId) {
changeJobDetailData.Supervisory_Organization_Reference = wdRef(
'Supervisory_Organization_ID',
data.newSupervisoryOrgId
)
}
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'staffing',
data.username,
data.password
)
const [result] = await client.Change_JobAsync({
Business_Process_Parameters: {
Auto_Complete: true,
Run_Now: true,
},
Change_Job_Data: {
Worker_Reference: wdRef('Employee_ID', data.workerId),
Effective_Date: data.effectiveDate,
Change_Job_Detail_Data: changeJobDetailData,
},
})
const eventRef = result?.Event_Reference
return NextResponse.json({
success: true,
output: {
eventId: extractRefId(eventRef),
workerId: data.workerId,
effectiveDate: data.effectiveDate,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday change job failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,134 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayCreatePrehireAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
legalName: z.string().min(1),
email: z.string().optional(),
phoneNumber: z.string().optional(),
address: z.string().optional(),
countryCode: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
if (!data.email && !data.phoneNumber && !data.address) {
return NextResponse.json(
{
success: false,
error: 'At least one contact method (email, phone, or address) is required',
},
{ status: 400 }
)
}
const parts = data.legalName.trim().split(/\s+/)
const firstName = parts[0] ?? ''
const lastName = parts.length > 1 ? parts.slice(1).join(' ') : ''
if (!lastName) {
return NextResponse.json(
{ success: false, error: 'Legal name must include both a first name and last name' },
{ status: 400 }
)
}
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'staffing',
data.username,
data.password
)
const contactData: Record<string, unknown> = {}
if (data.email) {
contactData.Email_Address_Data = [
{
Email_Address: data.email,
Usage_Data: {
Type_Data: { Type_Reference: wdRef('Communication_Usage_Type_ID', 'WORK') },
Public: true,
},
},
]
}
if (data.phoneNumber) {
contactData.Phone_Data = [
{
Phone_Number: data.phoneNumber,
Phone_Device_Type_Reference: wdRef('Phone_Device_Type_ID', 'Landline'),
Usage_Data: {
Type_Data: { Type_Reference: wdRef('Communication_Usage_Type_ID', 'WORK') },
Public: true,
},
},
]
}
if (data.address) {
contactData.Address_Data = [
{
Formatted_Address: data.address,
Usage_Data: {
Type_Data: { Type_Reference: wdRef('Communication_Usage_Type_ID', 'WORK') },
Public: true,
},
},
]
}
const [result] = await client.Put_ApplicantAsync({
Applicant_Data: {
Personal_Data: {
Name_Data: {
Legal_Name_Data: {
Name_Detail_Data: {
Country_Reference: wdRef('ISO_3166-1_Alpha-2_Code', data.countryCode ?? 'US'),
First_Name: firstName,
Last_Name: lastName,
},
},
},
Contact_Information_Data: contactData,
},
},
})
const applicantRef = result?.Applicant_Reference
return NextResponse.json({
success: true,
output: {
preHireId: extractRefId(applicantRef),
descriptor: applicantRef?.attributes?.Descriptor ?? null,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday create prehire failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,101 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
createWorkdaySoapClient,
extractRefId,
normalizeSoapArray,
type WorkdayCompensationDataSoap,
type WorkdayCompensationPlanSoap,
type WorkdayWorkerSoap,
} from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayGetCompensationAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const [result] = await client.Get_WorkersAsync({
Request_References: {
Worker_Reference: {
ID: { attributes: { 'wd:type': 'Employee_ID' }, $value: data.workerId },
},
},
Response_Group: {
Include_Reference: true,
Include_Compensation: true,
},
})
const worker =
normalizeSoapArray(
result?.Response_Data?.Worker as WorkdayWorkerSoap | WorkdayWorkerSoap[] | undefined
)[0] ?? null
const compensationData = worker?.Worker_Data?.Compensation_Data
const mapPlan = (p: WorkdayCompensationPlanSoap) => ({
id: extractRefId(p.Compensation_Plan_Reference) ?? null,
planName: p.Compensation_Plan_Reference?.attributes?.Descriptor ?? null,
amount: p.Amount ?? p.Per_Unit_Amount ?? p.Individual_Target_Amount ?? null,
currency: extractRefId(p.Currency_Reference) ?? null,
frequency: extractRefId(p.Frequency_Reference) ?? null,
})
const planTypeKeys: (keyof WorkdayCompensationDataSoap)[] = [
'Employee_Base_Pay_Plan_Assignment_Data',
'Employee_Salary_Unit_Plan_Assignment_Data',
'Employee_Bonus_Plan_Assignment_Data',
'Employee_Allowance_Plan_Assignment_Data',
'Employee_Commission_Plan_Assignment_Data',
'Employee_Stock_Plan_Assignment_Data',
'Employee_Period_Salary_Plan_Assignment_Data',
]
const compensationPlans: ReturnType<typeof mapPlan>[] = []
for (const key of planTypeKeys) {
for (const plan of normalizeSoapArray(compensationData?.[key])) {
compensationPlans.push(mapPlan(plan))
}
}
return NextResponse.json({
success: true,
output: { compensationPlans },
})
} catch (error) {
logger.error(`[${requestId}] Workday get compensation failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,94 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
createWorkdaySoapClient,
extractRefId,
normalizeSoapArray,
type WorkdayOrganizationSoap,
} from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayGetOrganizationsAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
type: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const limit = data.limit ?? 20
const offset = data.offset ?? 0
const page = offset > 0 ? Math.floor(offset / limit) + 1 : 1
const [result] = await client.Get_OrganizationsAsync({
Response_Filter: { Page: page, Count: limit },
Request_Criteria: data.type
? {
Organization_Type_Reference: {
ID: {
attributes: { 'wd:type': 'Organization_Type_ID' },
$value: data.type,
},
},
}
: undefined,
Response_Group: { Include_Hierarchy_Data: true },
})
const orgsArray = normalizeSoapArray(
result?.Response_Data?.Organization as
| WorkdayOrganizationSoap
| WorkdayOrganizationSoap[]
| undefined
)
const organizations = orgsArray.map((o) => ({
id: extractRefId(o.Organization_Reference) ?? null,
descriptor: o.Organization_Descriptor ?? null,
type: extractRefId(o.Organization_Data?.Organization_Type_Reference) ?? null,
subtype: extractRefId(o.Organization_Data?.Organization_Subtype_Reference) ?? null,
isActive: o.Organization_Data?.Inactive != null ? !o.Organization_Data.Inactive : null,
}))
const total = result?.Response_Results?.Total_Results ?? organizations.length
return NextResponse.json({
success: true,
output: { organizations, total },
})
} catch (error) {
logger.error(`[${requestId}] Workday get organizations failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,87 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
createWorkdaySoapClient,
extractRefId,
normalizeSoapArray,
type WorkdayWorkerSoap,
} from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayGetWorkerAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const [result] = await client.Get_WorkersAsync({
Request_References: {
Worker_Reference: {
ID: { attributes: { 'wd:type': 'Employee_ID' }, $value: data.workerId },
},
},
Response_Group: {
Include_Reference: true,
Include_Personal_Information: true,
Include_Employment_Information: true,
Include_Compensation: true,
Include_Organizations: true,
},
})
const worker =
normalizeSoapArray(
result?.Response_Data?.Worker as WorkdayWorkerSoap | WorkdayWorkerSoap[] | undefined
)[0] ?? null
return NextResponse.json({
success: true,
output: {
worker: worker
? {
id: extractRefId(worker.Worker_Reference) ?? null,
descriptor: worker.Worker_Descriptor ?? null,
personalData: worker.Worker_Data?.Personal_Data ?? null,
employmentData: worker.Worker_Data?.Employment_Data ?? null,
compensationData: worker.Worker_Data?.Compensation_Data ?? null,
organizationData: worker.Worker_Data?.Organization_Data ?? null,
}
: null,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday get worker failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,78 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayHireAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
preHireId: z.string().min(1),
positionId: z.string().min(1),
hireDate: z.string().min(1),
employeeType: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'staffing',
data.username,
data.password
)
const [result] = await client.Hire_EmployeeAsync({
Business_Process_Parameters: {
Auto_Complete: true,
Run_Now: true,
},
Hire_Employee_Data: {
Applicant_Reference: wdRef('Applicant_ID', data.preHireId),
Position_Reference: wdRef('Position_ID', data.positionId),
Hire_Date: data.hireDate,
Hire_Employee_Event_Data: {
Employee_Type_Reference: wdRef('Employee_Type_ID', data.employeeType ?? 'Regular'),
First_Day_of_Work: data.hireDate,
},
},
})
const employeeRef = result?.Employee_Reference
const eventRef = result?.Event_Reference
return NextResponse.json({
success: true,
output: {
workerId: extractRefId(employeeRef),
employeeId: extractRefId(employeeRef),
eventId: extractRefId(eventRef),
hireDate: data.hireDate,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday hire employee failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,83 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
createWorkdaySoapClient,
extractRefId,
normalizeSoapArray,
type WorkdayWorkerSoap,
} from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayListWorkersAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
limit: z.number().optional(),
offset: z.number().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const limit = data.limit ?? 20
const offset = data.offset ?? 0
const page = offset > 0 ? Math.floor(offset / limit) + 1 : 1
const [result] = await client.Get_WorkersAsync({
Response_Filter: { Page: page, Count: limit },
Response_Group: {
Include_Reference: true,
Include_Personal_Information: true,
Include_Employment_Information: true,
},
})
const workersArray = normalizeSoapArray(
result?.Response_Data?.Worker as WorkdayWorkerSoap | WorkdayWorkerSoap[] | undefined
)
const workers = workersArray.map((w) => ({
id: extractRefId(w.Worker_Reference) ?? null,
descriptor: w.Worker_Descriptor ?? null,
personalData: w.Worker_Data?.Personal_Data ?? null,
employmentData: w.Worker_Data?.Employment_Data ?? null,
}))
const total = result?.Response_Results?.Total_Results ?? workers.length
return NextResponse.json({
success: true,
output: { workers, total },
})
} catch (error) {
logger.error(`[${requestId}] Workday list workers failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,77 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayTerminateAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
terminationDate: z.string().min(1),
reason: z.string().min(1),
notificationDate: z.string().optional(),
lastDayOfWork: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'staffing',
data.username,
data.password
)
const [result] = await client.Terminate_EmployeeAsync({
Business_Process_Parameters: {
Auto_Complete: true,
Run_Now: true,
},
Terminate_Employee_Data: {
Employee_Reference: wdRef('Employee_ID', data.workerId),
Termination_Date: data.terminationDate,
Terminate_Event_Data: {
Primary_Reason_Reference: wdRef('Termination_Subcategory_ID', data.reason),
Last_Day_of_Work: data.lastDayOfWork ?? data.terminationDate,
Notification_Date: data.notificationDate ?? data.terminationDate,
},
},
})
const eventRef = result?.Event_Reference
return NextResponse.json({
success: true,
output: {
eventId: extractRefId(eventRef),
workerId: data.workerId,
terminationDate: data.terminationDate,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday terminate employee failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,66 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayUpdateWorkerAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
fields: z.record(z.unknown()),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const [result] = await client.Change_Personal_InformationAsync({
Business_Process_Parameters: {
Auto_Complete: true,
Run_Now: true,
},
Change_Personal_Information_Data: {
Person_Reference: wdRef('Employee_ID', data.workerId),
Personal_Information_Data: data.fields,
},
})
return NextResponse.json({
success: true,
output: {
eventId: extractRefId(result?.Personal_Information_Change_Event_Reference),
workerId: data.workerId,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday update worker failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -93,7 +93,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const fileName = rawFile.name || 'untitled'
const fileName = rawFile.name || 'untitled.md'
const maxSize = 100 * 1024 * 1024
if (rawFile.size > maxSize) {

View File

@@ -151,6 +151,8 @@ export function Files() {
}
const justCreatedFileIdRef = useRef<string | null>(null)
const filesRef = useRef(files)
filesRef.current = files
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
@@ -483,11 +485,11 @@ export function Files() {
if (isJustCreated) {
setPreviewMode('editor')
} else {
const file = selectedFileId ? files.find((f) => f.id === selectedFileId) : null
const file = selectedFileId ? filesRef.current.find((f) => f.id === selectedFileId) : null
const canPreview = file ? isPreviewable(file) : false
setPreviewMode(canPreview ? 'preview' : 'editor')
}
}, [selectedFileId, files])
}, [selectedFileId])
useEffect(() => {
if (!selectedFile) return

View File

@@ -168,7 +168,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
)}
{/* Header Section — only interactive area for dragging */}
{/* Header Section */}
<div
onClick={() => setCurrentBlockId(id)}
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
@@ -198,14 +198,15 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
</div>
{/*
* Subflow body background. Uses pointer-events: none so that edges rendered
* inside the subflow remain clickable. The subflow node wrapper also has
* pointer-events: none (set in workflow.tsx), so body-area clicks pass
* through to the pane. Subflow selection is done via the header above.
* Subflow body background. Captures clicks to select the subflow in the
* panel editor, matching the header click behavior. Child nodes and edges
* are rendered as sibling divs at the viewport level by ReactFlow (not as
* DOM children), so enabling pointer events here doesn't block them.
*/}
<div
className='absolute inset-0 top-[44px] rounded-b-[8px]'
style={{ pointerEvents: 'none' }}
className='workflow-drag-handle absolute inset-0 top-[44px] cursor-grab rounded-b-[8px] [&:active]:cursor-grabbing'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
onClick={() => setCurrentBlockId(id)}
/>
{!isPreview && (

View File

@@ -221,3 +221,68 @@ export function resolveParentChildSelectionConflicts(
return hasConflict ? resolved : nodes
}
export function getNodeSelectionContextId(
node: Pick<Node, 'id' | 'parentId'>,
blocks: Record<string, { data?: { parentId?: string } }>
): string | null {
return node.parentId || blocks[node.id]?.data?.parentId || null
}
export function getEdgeSelectionContextId(
edge: Pick<Edge, 'source' | 'target'>,
nodes: Array<Pick<Node, 'id' | 'parentId'>>,
blocks: Record<string, { data?: { parentId?: string } }>
): string | null {
const sourceNode = nodes.find((node) => node.id === edge.source)
const targetNode = nodes.find((node) => node.id === edge.target)
const sourceContextId = sourceNode ? getNodeSelectionContextId(sourceNode, blocks) : null
const targetContextId = targetNode ? getNodeSelectionContextId(targetNode, blocks) : null
if (sourceContextId) return sourceContextId
if (targetContextId) return targetContextId
return null
}
export function resolveSelectionContextConflicts(
nodes: Node[],
blocks: Record<string, { data?: { parentId?: string } }>,
preferredContextId?: string | null
): Node[] {
const selectedNodes = nodes.filter((node) => node.selected)
if (selectedNodes.length <= 1) return nodes
const allowedContextId =
preferredContextId !== undefined
? preferredContextId
: getNodeSelectionContextId(selectedNodes[0], blocks)
let hasConflict = false
const resolved = nodes.map((node) => {
if (!node.selected) return node
const contextId = getNodeSelectionContextId(node, blocks)
if (contextId !== allowedContextId) {
hasConflict = true
return { ...node, selected: false }
}
return node
})
return hasConflict ? resolved : nodes
}
export function resolveSelectionConflicts(
nodes: Node[],
blocks: Record<string, { data?: { parentId?: string } }>,
preferredNodeId?: string
): Node[] {
const afterParentChild = resolveParentChildSelectionConflicts(nodes, blocks)
const preferredContextId =
preferredNodeId !== undefined
? afterParentChild.find((n) => n.id === preferredNodeId && n.selected)
? getNodeSelectionContextId(afterParentChild.find((n) => n.id === preferredNodeId)!, blocks)
: undefined
: undefined
return resolveSelectionContextConflicts(afterParentChild, blocks, preferredContextId)
}

View File

@@ -59,11 +59,13 @@ import {
filterProtectedBlocks,
getClampedPositionForNode,
getDescendantBlockIds,
getEdgeSelectionContextId,
getNodeSelectionContextId,
getWorkflowLockToggleIds,
isBlockProtected,
isEdgeProtected,
isInEditableElement,
resolveParentChildSelectionConflicts,
resolveSelectionConflicts,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useSocket } from '@/app/workspace/providers/socket-provider'
@@ -168,16 +170,17 @@ function mapEdgesByNode(edges: Edge[], nodeIds: Set<string>): Map<string, Edge[]
/**
* Syncs the panel editor with the current selection state.
* Shows block details when exactly one block is selected, clears otherwise.
* Shows the last selected block in the panel. Clears when nothing is selected.
*/
function syncPanelWithSelection(selectedIds: string[]) {
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } = usePanelEditorStore.getState()
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
setCurrentBlockId(selectedIds[0])
} else if (selectedIds.length === 0 && currentBlockId) {
clearCurrentBlock()
} else if (selectedIds.length > 1 && currentBlockId) {
clearCurrentBlock()
if (selectedIds.length === 0) {
if (currentBlockId) clearCurrentBlock()
} else {
const lastSelectedId = selectedIds[selectedIds.length - 1]
if (lastSelectedId !== currentBlockId) {
setCurrentBlockId(lastSelectedId)
}
}
}
@@ -246,7 +249,6 @@ const WorkflowContent = React.memo(
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(new Map())
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
const canvasContainerRef = useRef<HTMLDivElement>(null)
const selectedIdsRef = useRef<string[] | null>(null)
const embeddedFitFrameRef = useRef<number | null>(null)
const hasCompletedInitialEmbeddedFitRef = useRef(false)
const canvasMode = useCanvasModeStore((state) => state.mode)
@@ -2477,6 +2479,16 @@ const WorkflowContent = React.memo(
// Local state for nodes - allows smooth drag without store updates on every frame
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
const selectedNodeIds = useMemo(
() => displayNodes.filter((node) => node.selected).map((node) => node.id),
[displayNodes]
)
const selectedNodeIdsKey = selectedNodeIds.join(',')
useEffect(() => {
syncPanelWithSelection(selectedNodeIds)
}, [selectedNodeIdsKey])
useEffect(() => {
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
if (pendingSelection && pendingSelection.length > 0) {
@@ -2488,10 +2500,8 @@ const WorkflowContent = React.memo(
...node,
selected: pendingSet.has(node.id),
}))
const resolved = resolveParentChildSelectionConflicts(withSelection, blocks)
const resolved = resolveSelectionConflicts(withSelection, blocks)
setDisplayNodes(resolved)
const selectedIds = resolved.filter((node) => node.selected).map((node) => node.id)
syncPanelWithSelection(selectedIds)
return
}
@@ -2709,19 +2719,20 @@ const WorkflowContent = React.memo(
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
selectedIdsRef.current = null
setDisplayNodes((nds) => {
const updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select')
const hasSelectionChange = changes.some((c) => c.type === 'select')
setDisplayNodes((currentNodes) => {
const updated = applyNodeChanges(changes, currentNodes)
if (!hasSelectionChange) return updated
const resolved = resolveParentChildSelectionConflicts(updated, blocks)
selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id)
return resolved
const preferredNodeId = [...changes]
.reverse()
.find(
(change): change is NodeChange & { id: string; selected: boolean } =>
change.type === 'select' && 'selected' in change && change.selected === true
)?.id
return resolveSelectionConflicts(updated, blocks, preferredNodeId)
})
const selectedIds = selectedIdsRef.current as string[] | null
if (selectedIds !== null) {
syncPanelWithSelection(selectedIds)
}
// Handle position changes (e.g., from keyboard arrow key movement)
// Update container dimensions when child nodes are moved and persist to backend
@@ -3160,7 +3171,10 @@ const WorkflowContent = React.memo(
parentId: currentParentId,
})
// Capture all selected nodes' positions for multi-node undo/redo
// Capture all selected nodes' positions for multi-node undo/redo.
// Also include the dragged node itself — during shift+click+drag, ReactFlow
// may have toggled (deselected) the node before drag starts, so it might not
// appear in the selected set yet.
const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
multiNodeDragStartRef.current.clear()
@@ -3174,6 +3188,33 @@ const WorkflowContent = React.memo(
})
}
})
if (!multiNodeDragStartRef.current.has(node.id)) {
multiNodeDragStartRef.current.set(node.id, {
x: node.position.x,
y: node.position.y,
parentId: currentParentId ?? undefined,
})
}
// When shift+clicking an already-selected node, ReactFlow toggles (deselects)
// it via onNodesChange before drag starts. Re-select the dragged node so all
// previously selected nodes move together as a group — but only if the
// deselection wasn't from a parent-child conflict (e.g. dragging a child
// when its parent subflow is selected).
const draggedNodeInSelected = allNodes.find((n) => n.id === node.id)
if (draggedNodeInSelected && !draggedNodeInSelected.selected && selectedNodes.length > 0) {
const draggedParentId = blocks[node.id]?.data?.parentId
const parentIsSelected =
draggedParentId && selectedNodes.some((n) => n.id === draggedParentId)
const contextMismatch =
getNodeSelectionContextId(draggedNodeInSelected, blocks) !==
getNodeSelectionContextId(selectedNodes[0], blocks)
if (!parentIsSelected && !contextMismatch) {
setDisplayNodes((currentNodes) =>
currentNodes.map((n) => (n.id === node.id ? { ...n, selected: true } : n))
)
}
}
},
[blocks, setDragStartPosition, getNodes, setPotentialParentId]
)
@@ -3453,7 +3494,7 @@ const WorkflowContent = React.memo(
})
// Apply visual deselection of children
setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks))
setDisplayNodes((allNodes) => resolveSelectionConflicts(allNodes, blocks))
},
[blocks]
)
@@ -3604,19 +3645,25 @@ const WorkflowContent = React.memo(
/**
* Handles node click to select the node in ReactFlow.
* Parent-child conflict resolution happens automatically in onNodesChange.
* Uses the controlled display node state so parent-child conflicts are resolved
* consistently for click, shift-click, and marquee selection.
*/
const handleNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
setNodes((nodes) =>
nodes.map((n) => ({
...n,
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
setDisplayNodes((currentNodes) => {
const updated = currentNodes.map((currentNode) => ({
...currentNode,
selected: isMultiSelect
? currentNode.id === node.id
? true
: currentNode.selected
: currentNode.id === node.id,
}))
)
return resolveSelectionConflicts(updated, blocks, isMultiSelect ? node.id : undefined)
})
},
[setNodes]
[blocks]
)
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
@@ -3624,16 +3671,10 @@ const WorkflowContent = React.memo(
(event: React.MouseEvent, edge: any) => {
event.stopPropagation() // Prevent bubbling
// Determine if edge is inside a loop by checking its source/target nodes
const sourceNode = getNodes().find((n) => n.id === edge.source)
const targetNode = getNodes().find((n) => n.id === edge.target)
// An edge is inside a loop if either source or target has a parent
// If source and target have different parents, prioritize source's parent
const parentLoopId = sourceNode?.parentId || targetNode?.parentId
// Create a unique identifier that combines edge ID and parent context
const contextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}`
const contextId = `${edge.id}${(() => {
const selectionContextId = getEdgeSelectionContextId(edge, getNodes(), blocks)
return selectionContextId ? `-${selectionContextId}` : ''
})()}`
if (event.shiftKey) {
// Shift-click: toggle edge in selection
@@ -3651,7 +3692,7 @@ const WorkflowContent = React.memo(
setSelectedEdges(new Map([[contextId, edge.id]]))
}
},
[getNodes]
[blocks, getNodes]
)
/** Stable delete handler to avoid creating new function references per edge. */

View File

@@ -7,7 +7,7 @@ export const AshbyBlock: BlockConfig = {
name: 'Ashby',
description: 'Manage candidates, jobs, and applications in Ashby',
longDescription:
'Integrate Ashby into the workflow. Can list, search, create, and update candidates, list and get job details, create notes, list notes, list and get applications, create applications, and list offers.',
'Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag), applications (list, get, create, change stage), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).',
docsLink: 'https://docs.sim.ai/tools/ashby',
category: 'tools',
bgColor: '#5D4ED6',
@@ -45,6 +45,21 @@ export const AshbyBlock: BlockConfig = {
{ label: 'Get Application', id: 'get_application' },
{ label: 'Create Application', id: 'create_application' },
{ label: 'List Offers', id: 'list_offers' },
{ label: 'Change Application Stage', id: 'change_application_stage' },
{ label: 'Add Candidate Tag', id: 'add_candidate_tag' },
{ label: 'Remove Candidate Tag', id: 'remove_candidate_tag' },
{ label: 'Get Offer', id: 'get_offer' },
{ label: 'List Sources', id: 'list_sources' },
{ label: 'List Candidate Tags', id: 'list_candidate_tags' },
{ label: 'List Archive Reasons', id: 'list_archive_reasons' },
{ label: 'List Custom Fields', id: 'list_custom_fields' },
{ label: 'List Departments', id: 'list_departments' },
{ label: 'List Locations', id: 'list_locations' },
{ label: 'List Job Postings', id: 'list_job_postings' },
{ label: 'Get Job Posting', id: 'get_job_posting' },
{ label: 'List Openings', id: 'list_openings' },
{ label: 'List Users', id: 'list_users' },
{ label: 'List Interviews', id: 'list_interviews' },
],
value: () => 'list_candidates',
},
@@ -56,24 +71,34 @@ export const AshbyBlock: BlockConfig = {
placeholder: 'Enter your Ashby API key',
password: true,
},
// Get Candidate / Create Note / List Notes / Update Candidate - candidateId
{
id: 'candidateId',
title: 'Candidate ID',
type: 'short-input',
required: {
field: 'operation',
value: ['get_candidate', 'create_note', 'list_notes', 'update_candidate'],
value: [
'get_candidate',
'create_note',
'list_notes',
'update_candidate',
'add_candidate_tag',
'remove_candidate_tag',
],
},
placeholder: 'Enter candidate UUID',
condition: {
field: 'operation',
value: ['get_candidate', 'create_note', 'list_notes', 'update_candidate'],
value: [
'get_candidate',
'create_note',
'list_notes',
'update_candidate',
'add_candidate_tag',
'remove_candidate_tag',
],
},
},
// Create Candidate fields
{
id: 'name',
title: 'Name',
@@ -86,22 +111,10 @@ export const AshbyBlock: BlockConfig = {
id: 'email',
title: 'Email',
type: 'short-input',
required: { field: 'operation', value: 'create_candidate' },
placeholder: 'Email address',
condition: { field: 'operation', value: ['create_candidate', 'update_candidate'] },
},
{
id: 'emailType',
title: 'Email Type',
type: 'dropdown',
options: [
{ label: 'Work', id: 'Work' },
{ label: 'Personal', id: 'Personal' },
{ label: 'Other', id: 'Other' },
],
value: () => 'Work',
condition: { field: 'operation', value: ['create_candidate', 'update_candidate'] },
mode: 'advanced',
},
{
id: 'phoneNumber',
title: 'Phone Number',
@@ -110,19 +123,6 @@ export const AshbyBlock: BlockConfig = {
condition: { field: 'operation', value: ['create_candidate', 'update_candidate'] },
mode: 'advanced',
},
{
id: 'phoneType',
title: 'Phone Type',
type: 'dropdown',
options: [
{ label: 'Work', id: 'Work' },
{ label: 'Personal', id: 'Personal' },
{ label: 'Other', id: 'Other' },
],
value: () => 'Work',
condition: { field: 'operation', value: ['create_candidate', 'update_candidate'] },
mode: 'advanced',
},
{
id: 'linkedInUrl',
title: 'LinkedIn URL',
@@ -150,8 +150,6 @@ export const AshbyBlock: BlockConfig = {
},
mode: 'advanced',
},
// Update Candidate fields
{
id: 'updateName',
title: 'Name',
@@ -168,8 +166,6 @@ export const AshbyBlock: BlockConfig = {
condition: { field: 'operation', value: 'update_candidate' },
mode: 'advanced',
},
// Search Candidates fields
{
id: 'searchName',
title: 'Name',
@@ -184,8 +180,6 @@ export const AshbyBlock: BlockConfig = {
placeholder: 'Search by candidate email',
condition: { field: 'operation', value: 'search_candidates' },
},
// Get Job fields
{
id: 'jobId',
title: 'Job ID',
@@ -194,18 +188,20 @@ export const AshbyBlock: BlockConfig = {
placeholder: 'Enter job UUID',
condition: { field: 'operation', value: ['get_job', 'create_application'] },
},
// Get Application fields
{
id: 'applicationId',
title: 'Application ID',
type: 'short-input',
required: { field: 'operation', value: 'get_application' },
required: {
field: 'operation',
value: ['get_application', 'change_application_stage'],
},
placeholder: 'Enter application UUID',
condition: { field: 'operation', value: 'get_application' },
condition: {
field: 'operation',
value: ['get_application', 'change_application_stage', 'list_interviews'],
},
},
// Create Application fields
{
id: 'appCandidateId',
title: 'Candidate ID',
@@ -226,9 +222,12 @@ export const AshbyBlock: BlockConfig = {
id: 'interviewStageId',
title: 'Interview Stage ID',
type: 'short-input',
placeholder: 'Interview stage UUID (defaults to first Lead stage)',
condition: { field: 'operation', value: 'create_application' },
mode: 'advanced',
required: { field: 'operation', value: 'change_application_stage' },
placeholder: 'Interview stage UUID',
condition: {
field: 'operation',
value: ['create_application', 'change_application_stage', 'list_interviews'],
},
},
{
id: 'creditedToUserId',
@@ -257,8 +256,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
generationType: 'timestamp',
},
},
// Create Note fields
{
id: 'note',
title: 'Note',
@@ -286,8 +283,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
condition: { field: 'operation', value: 'create_note' },
mode: 'advanced',
},
// List Applications filter fields
{
id: 'filterStatus',
title: 'Status Filter',
@@ -338,8 +333,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
generationType: 'timestamp',
},
},
// List Jobs status filter
{
id: 'jobStatus',
title: 'Status Filter',
@@ -355,8 +348,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
condition: { field: 'operation', value: 'list_jobs' },
mode: 'advanced',
},
// Pagination fields for list operations
{
id: 'cursor',
title: 'Cursor',
@@ -364,7 +355,16 @@ Output only the ISO 8601 timestamp string, nothing else.`,
placeholder: 'Pagination cursor from previous response',
condition: {
field: 'operation',
value: ['list_candidates', 'list_jobs', 'list_applications', 'list_notes', 'list_offers'],
value: [
'list_candidates',
'list_jobs',
'list_applications',
'list_notes',
'list_offers',
'list_openings',
'list_users',
'list_interviews',
],
},
mode: 'advanced',
},
@@ -375,12 +375,57 @@ Output only the ISO 8601 timestamp string, nothing else.`,
placeholder: 'Results per page (default 100)',
condition: {
field: 'operation',
value: ['list_candidates', 'list_jobs', 'list_applications', 'list_notes', 'list_offers'],
value: [
'list_candidates',
'list_jobs',
'list_applications',
'list_notes',
'list_offers',
'list_openings',
'list_users',
'list_interviews',
],
},
mode: 'advanced',
},
// Trigger subBlocks
{
id: 'tagId',
title: 'Tag ID',
type: 'short-input',
required: {
field: 'operation',
value: ['add_candidate_tag', 'remove_candidate_tag'],
},
placeholder: 'Enter tag UUID',
condition: {
field: 'operation',
value: ['add_candidate_tag', 'remove_candidate_tag'],
},
},
{
id: 'archiveReasonId',
title: 'Archive Reason ID',
type: 'short-input',
placeholder: 'Archive reason UUID (required for Archived stages)',
condition: { field: 'operation', value: 'change_application_stage' },
mode: 'advanced',
},
{
id: 'offerId',
title: 'Offer ID',
type: 'short-input',
required: { field: 'operation', value: 'get_offer' },
placeholder: 'Enter offer UUID',
condition: { field: 'operation', value: 'get_offer' },
},
{
id: 'jobPostingId',
title: 'Job Posting ID',
type: 'short-input',
required: { field: 'operation', value: 'get_job_posting' },
placeholder: 'Enter job posting UUID',
condition: { field: 'operation', value: 'get_job_posting' },
},
...getTrigger('ashby_application_submit').subBlocks,
...getTrigger('ashby_candidate_stage_change').subBlocks,
...getTrigger('ashby_candidate_hire').subBlocks,
@@ -391,17 +436,32 @@ Output only the ISO 8601 timestamp string, nothing else.`,
tools: {
access: [
'ashby_add_candidate_tag',
'ashby_change_application_stage',
'ashby_create_application',
'ashby_create_candidate',
'ashby_create_note',
'ashby_get_application',
'ashby_get_candidate',
'ashby_get_job',
'ashby_get_job_posting',
'ashby_get_offer',
'ashby_list_applications',
'ashby_list_archive_reasons',
'ashby_list_candidate_tags',
'ashby_list_candidates',
'ashby_list_custom_fields',
'ashby_list_departments',
'ashby_list_interviews',
'ashby_list_job_postings',
'ashby_list_jobs',
'ashby_list_locations',
'ashby_list_notes',
'ashby_list_offers',
'ashby_list_openings',
'ashby_list_sources',
'ashby_list_users',
'ashby_remove_candidate_tag',
'ashby_search_candidates',
'ashby_update_candidate',
],
@@ -419,10 +479,8 @@ Output only the ISO 8601 timestamp string, nothing else.`,
if (params.sendNotifications === 'true' || params.sendNotifications === true) {
result.sendNotifications = true
}
// Create Application params
if (params.appCandidateId) result.candidateId = params.appCandidateId
if (params.appCreatedAt) result.createdAt = params.appCreatedAt
// Update Candidate params
if (params.updateName) result.name = params.updateName
return result
},
@@ -435,9 +493,7 @@ Output only the ISO 8601 timestamp string, nothing else.`,
candidateId: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Candidate full name' },
email: { type: 'string', description: 'Email address' },
emailType: { type: 'string', description: 'Email type (Personal, Work, Other)' },
phoneNumber: { type: 'string', description: 'Phone number' },
phoneType: { type: 'string', description: 'Phone type (Personal, Work, Other)' },
linkedInUrl: { type: 'string', description: 'LinkedIn profile URL' },
githubUrl: { type: 'string', description: 'GitHub profile URL' },
websiteUrl: { type: 'string', description: 'Personal website URL' },
@@ -462,6 +518,10 @@ Output only the ISO 8601 timestamp string, nothing else.`,
jobStatus: { type: 'string', description: 'Job status filter' },
cursor: { type: 'string', description: 'Pagination cursor' },
perPage: { type: 'number', description: 'Results per page' },
tagId: { type: 'string', description: 'Tag UUID' },
offerId: { type: 'string', description: 'Offer UUID' },
jobPostingId: { type: 'string', description: 'Job posting UUID' },
archiveReasonId: { type: 'string', description: 'Archive reason UUID' },
},
outputs: {
@@ -486,12 +546,73 @@ Output only the ISO 8601 timestamp string, nothing else.`,
},
offers: {
type: 'json',
description: 'List of offers (id, status, candidate, job, createdAt, updatedAt)',
description:
'List of offers (id, offerStatus, acceptanceStatus, applicationId, startDate, salary, openingId)',
},
archiveReasons: {
type: 'json',
description: 'List of archive reasons (id, text, reasonType, isArchived)',
},
sources: {
type: 'json',
description: 'List of sources (id, title, isArchived)',
},
customFields: {
type: 'json',
description: 'List of custom fields (id, title, fieldType, objectType, isArchived)',
},
departments: {
type: 'json',
description: 'List of departments (id, name, isArchived, parentId)',
},
locations: {
type: 'json',
description: 'List of locations (id, name, isArchived, isRemote, address)',
},
jobPostings: {
type: 'json',
description:
'List of job postings (id, title, jobId, locationName, departmentName, employmentType, isListed, publishedDate)',
},
openings: {
type: 'json',
description: 'List of openings (id, openingState, isArchived, openedAt, closedAt)',
},
users: {
type: 'json',
description: 'List of users (id, firstName, lastName, email, isEnabled, globalRole)',
},
interviewSchedules: {
type: 'json',
description:
'List of interview schedules (id, applicationId, interviewStageId, status, createdAt)',
},
tags: {
type: 'json',
description: 'List of candidate tags (id, title, isArchived)',
},
stageId: { type: 'string', description: 'Interview stage UUID after stage change' },
success: { type: 'boolean', description: 'Whether the operation succeeded' },
offerStatus: {
type: 'string',
description: 'Offer status (e.g. WaitingOnCandidateResponse, CandidateAccepted)',
},
acceptanceStatus: {
type: 'string',
description: 'Acceptance status (e.g. Accepted, Declined, Pending)',
},
applicationId: { type: 'string', description: 'Associated application UUID' },
openingId: { type: 'string', description: 'Opening UUID associated with the offer' },
salary: {
type: 'json',
description: 'Salary details from latest version (currencyCode, value)',
},
startDate: { type: 'string', description: 'Offer start date from latest version' },
id: { type: 'string', description: 'Resource UUID' },
name: { type: 'string', description: 'Resource name' },
title: { type: 'string', description: 'Job title' },
status: { type: 'string', description: 'Status' },
noteId: { type: 'string', description: 'Created note UUID' },
content: { type: 'string', description: 'Note content' },
moreDataAvailable: { type: 'boolean', description: 'Whether more pages exist' },
nextCursor: { type: 'string', description: 'Pagination cursor for next page' },

View File

@@ -0,0 +1,599 @@
import { BoxCompanyIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
export const BoxBlock: BlockConfig = {
type: 'box',
name: 'Box',
description: 'Manage files, folders, and e-signatures with Box',
longDescription:
'Integrate Box into your workflow to manage files, folders, and e-signatures. Upload and download files, search content, create folders, send documents for e-signature, track signing status, and more.',
docsLink: 'https://docs.sim.ai/tools/box',
category: 'tools',
bgColor: '#FFFFFF',
icon: BoxCompanyIcon,
authMode: AuthMode.OAuth,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Upload File', id: 'upload_file' },
{ label: 'Download File', id: 'download_file' },
{ label: 'Get File Info', id: 'get_file_info' },
{ label: 'List Folder Items', id: 'list_folder_items' },
{ label: 'Create Folder', id: 'create_folder' },
{ label: 'Delete File', id: 'delete_file' },
{ label: 'Delete Folder', id: 'delete_folder' },
{ label: 'Copy File', id: 'copy_file' },
{ label: 'Search', id: 'search' },
{ label: 'Update File', id: 'update_file' },
{ label: 'Create Sign Request', id: 'sign_create_request' },
{ label: 'Get Sign Request', id: 'sign_get_request' },
{ label: 'List Sign Requests', id: 'sign_list_requests' },
{ label: 'Cancel Sign Request', id: 'sign_cancel_request' },
{ label: 'Resend Sign Request', id: 'sign_resend_request' },
],
value: () => 'upload_file',
},
{
id: 'credential',
title: 'Box Account',
type: 'oauth-input',
serviceId: 'box',
requiredScopes: getScopesForService('box'),
placeholder: 'Select Box account',
required: true,
},
// Upload File fields
{
id: 'uploadFile',
title: 'File',
type: 'file-upload',
canonicalParamId: 'file',
placeholder: 'Upload file to send to Box',
mode: 'basic',
multiple: false,
required: { field: 'operation', value: 'upload_file' },
condition: { field: 'operation', value: 'upload_file' },
},
{
id: 'fileRef',
title: 'File',
type: 'short-input',
canonicalParamId: 'file',
placeholder: 'Reference file from previous blocks',
mode: 'advanced',
required: { field: 'operation', value: 'upload_file' },
condition: { field: 'operation', value: 'upload_file' },
},
{
id: 'parentFolderId',
title: 'Parent Folder ID',
type: 'short-input',
placeholder: 'Folder ID (use "0" for root)',
required: { field: 'operation', value: ['upload_file', 'create_folder', 'copy_file'] },
condition: { field: 'operation', value: ['upload_file', 'create_folder', 'copy_file'] },
},
{
id: 'uploadFileName',
title: 'File Name',
type: 'short-input',
placeholder: 'Optional filename override',
condition: { field: 'operation', value: 'upload_file' },
mode: 'advanced',
},
// File ID field (shared by download, get info, delete, copy, update)
{
id: 'fileId',
title: 'File ID',
type: 'short-input',
placeholder: 'Box file ID',
required: {
field: 'operation',
value: ['download_file', 'get_file_info', 'delete_file', 'copy_file', 'update_file'],
},
condition: {
field: 'operation',
value: ['download_file', 'get_file_info', 'delete_file', 'copy_file', 'update_file'],
},
},
// Folder ID field (shared by list, delete folder)
{
id: 'folderId',
title: 'Folder ID',
type: 'short-input',
placeholder: 'Box folder ID (use "0" for root)',
required: { field: 'operation', value: ['list_folder_items', 'delete_folder'] },
condition: { field: 'operation', value: ['list_folder_items', 'delete_folder'] },
},
// Create Folder fields
{
id: 'folderName',
title: 'Folder Name',
type: 'short-input',
placeholder: 'Name for the new folder',
required: { field: 'operation', value: 'create_folder' },
condition: { field: 'operation', value: 'create_folder' },
},
// Copy File fields
{
id: 'copyName',
title: 'New Name',
type: 'short-input',
placeholder: 'Optional name for the copy',
condition: { field: 'operation', value: 'copy_file' },
},
// Search fields
{
id: 'query',
title: 'Search Query',
type: 'short-input',
placeholder: 'Search query string',
required: { field: 'operation', value: 'search' },
condition: { field: 'operation', value: 'search' },
},
{
id: 'ancestorFolderId',
title: 'Ancestor Folder ID',
type: 'short-input',
placeholder: 'Restrict search to a folder',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'fileExtensions',
title: 'File Extensions',
type: 'short-input',
placeholder: 'e.g., pdf,docx,xlsx',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'contentType',
title: 'Content Type',
type: 'dropdown',
options: [
{ label: 'All', id: '' },
{ label: 'Files', id: 'file' },
{ label: 'Folders', id: 'folder' },
{ label: 'Web Links', id: 'web_link' },
],
value: () => '',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
// Update File fields
{
id: 'newName',
title: 'New Name',
type: 'short-input',
placeholder: 'Rename the file',
condition: { field: 'operation', value: 'update_file' },
},
{
id: 'description',
title: 'Description',
type: 'short-input',
placeholder: 'File description (max 256 chars)',
condition: { field: 'operation', value: 'update_file' },
},
{
id: 'moveToFolderId',
title: 'Move to Folder ID',
type: 'short-input',
placeholder: 'Move file to this folder',
condition: { field: 'operation', value: 'update_file' },
mode: 'advanced',
},
{
id: 'tags',
title: 'Tags',
type: 'short-input',
placeholder: 'Comma-separated tags',
condition: { field: 'operation', value: 'update_file' },
mode: 'advanced',
},
// Delete Folder options
{
id: 'recursive',
title: 'Delete Recursively',
type: 'switch',
condition: { field: 'operation', value: 'delete_folder' },
},
// Shared pagination fields (file operations)
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: 'Max results per page',
condition: {
field: 'operation',
value: ['list_folder_items', 'search', 'sign_list_requests'],
},
mode: 'advanced',
},
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: 'Pagination offset',
condition: { field: 'operation', value: ['list_folder_items', 'search'] },
mode: 'advanced',
},
// List Folder sort options
{
id: 'sort',
title: 'Sort By',
type: 'dropdown',
options: [
{ label: 'Default', id: '' },
{ label: 'ID', id: 'id' },
{ label: 'Name', id: 'name' },
{ label: 'Date', id: 'date' },
{ label: 'Size', id: 'size' },
],
value: () => '',
condition: { field: 'operation', value: 'list_folder_items' },
mode: 'advanced',
},
{
id: 'direction',
title: 'Sort Direction',
type: 'dropdown',
options: [
{ label: 'Ascending', id: 'ASC' },
{ label: 'Descending', id: 'DESC' },
],
value: () => 'ASC',
condition: { field: 'operation', value: 'list_folder_items' },
mode: 'advanced',
},
// Sign Request fields
{
id: 'sourceFileIds',
title: 'Source File IDs',
type: 'short-input',
placeholder: 'Comma-separated Box file IDs (e.g., 12345,67890)',
required: { field: 'operation', value: 'sign_create_request' },
condition: { field: 'operation', value: 'sign_create_request' },
},
{
id: 'signerEmail',
title: 'Signer Email',
type: 'short-input',
placeholder: 'Primary signer email address',
required: { field: 'operation', value: 'sign_create_request' },
condition: { field: 'operation', value: 'sign_create_request' },
},
{
id: 'signerRole',
title: 'Signer Role',
type: 'dropdown',
options: [
{ label: 'Signer', id: 'signer' },
{ label: 'Approver', id: 'approver' },
{ label: 'Final Copy Reader', id: 'final_copy_reader' },
],
value: () => 'signer',
condition: { field: 'operation', value: 'sign_create_request' },
},
{
id: 'emailSubject',
title: 'Email Subject',
type: 'short-input',
placeholder: 'Custom email subject line',
condition: { field: 'operation', value: 'sign_create_request' },
},
{
id: 'emailMessage',
title: 'Email Message',
type: 'long-input',
placeholder: 'Custom message in the signing email',
condition: { field: 'operation', value: 'sign_create_request' },
},
{
id: 'signRequestName',
title: 'Request Name',
type: 'short-input',
placeholder: 'Name for this sign request',
condition: { field: 'operation', value: 'sign_create_request' },
},
{
id: 'additionalSigners',
title: 'Additional Signers',
type: 'long-input',
placeholder: '[{"email":"user@example.com","role":"signer"}]',
condition: { field: 'operation', value: 'sign_create_request' },
mode: 'advanced',
},
{
id: 'signParentFolderId',
title: 'Destination Folder ID',
type: 'short-input',
placeholder: 'Box folder ID for signed documents',
condition: { field: 'operation', value: 'sign_create_request' },
mode: 'advanced',
},
{
id: 'daysValid',
title: 'Days Valid',
type: 'short-input',
placeholder: 'Number of days before expiry (0-730)',
condition: { field: 'operation', value: 'sign_create_request' },
mode: 'advanced',
},
{
id: 'areRemindersEnabled',
title: 'Enable Reminders',
type: 'switch',
condition: { field: 'operation', value: 'sign_create_request' },
mode: 'advanced',
},
{
id: 'areTextSignaturesEnabled',
title: 'Allow Text Signatures',
type: 'switch',
condition: { field: 'operation', value: 'sign_create_request' },
mode: 'advanced',
},
{
id: 'signatureColor',
title: 'Signature Color',
type: 'dropdown',
options: [
{ label: 'Blue', id: 'blue' },
{ label: 'Black', id: 'black' },
{ label: 'Red', id: 'red' },
],
value: () => 'blue',
condition: { field: 'operation', value: 'sign_create_request' },
mode: 'advanced',
},
{
id: 'redirectUrl',
title: 'Redirect URL',
type: 'short-input',
placeholder: 'URL to redirect after signing',
condition: { field: 'operation', value: 'sign_create_request' },
mode: 'advanced',
},
{
id: 'declinedRedirectUrl',
title: 'Declined Redirect URL',
type: 'short-input',
placeholder: 'URL to redirect after declining',
condition: { field: 'operation', value: 'sign_create_request' },
mode: 'advanced',
},
{
id: 'isDocumentPreparationNeeded',
title: 'Document Preparation Needed',
type: 'switch',
condition: { field: 'operation', value: 'sign_create_request' },
mode: 'advanced',
},
{
id: 'externalId',
title: 'External ID',
type: 'short-input',
placeholder: 'External system reference ID',
condition: { field: 'operation', value: 'sign_create_request' },
mode: 'advanced',
},
// Sign Request ID (shared by get, cancel, resend)
{
id: 'signRequestId',
title: 'Sign Request ID',
type: 'short-input',
placeholder: 'Box Sign request ID',
required: {
field: 'operation',
value: ['sign_get_request', 'sign_cancel_request', 'sign_resend_request'],
},
condition: {
field: 'operation',
value: ['sign_get_request', 'sign_cancel_request', 'sign_resend_request'],
},
},
// Sign list pagination marker
{
id: 'marker',
title: 'Pagination Marker',
type: 'short-input',
placeholder: 'Marker from previous response',
condition: { field: 'operation', value: 'sign_list_requests' },
mode: 'advanced',
},
],
tools: {
access: [
'box_upload_file',
'box_download_file',
'box_get_file_info',
'box_list_folder_items',
'box_create_folder',
'box_delete_file',
'box_delete_folder',
'box_copy_file',
'box_search',
'box_update_file',
'box_sign_create_request',
'box_sign_get_request',
'box_sign_list_requests',
'box_sign_cancel_request',
'box_sign_resend_request',
],
config: {
tool: (params) => {
const op = params.operation as string
if (op.startsWith('sign_')) {
return `box_${op}`
}
return `box_${op}`
},
params: (params) => {
const normalizedFile = normalizeFileInput(params.file, { single: true })
if (normalizedFile) {
params.file = normalizedFile
}
const { credential, operation, ...rest } = params
const baseParams: Record<string, unknown> = {
accessToken: credential,
}
switch (operation) {
case 'upload_file':
baseParams.parentFolderId = rest.parentFolderId
baseParams.file = rest.file
if (rest.uploadFileName) baseParams.fileName = rest.uploadFileName
break
case 'download_file':
case 'get_file_info':
case 'delete_file':
baseParams.fileId = rest.fileId
break
case 'list_folder_items':
baseParams.folderId = rest.folderId
if (rest.limit) baseParams.limit = Number(rest.limit)
if (rest.offset) baseParams.offset = Number(rest.offset)
if (rest.sort) baseParams.sort = rest.sort
if (rest.direction) baseParams.direction = rest.direction
break
case 'create_folder':
baseParams.name = rest.folderName
baseParams.parentFolderId = rest.parentFolderId
break
case 'delete_folder':
baseParams.folderId = rest.folderId
if (rest.recursive !== undefined) baseParams.recursive = rest.recursive
break
case 'copy_file':
baseParams.fileId = rest.fileId
baseParams.parentFolderId = rest.parentFolderId
if (rest.copyName) baseParams.name = rest.copyName
break
case 'search':
baseParams.query = rest.query
if (rest.limit) baseParams.limit = Number(rest.limit)
if (rest.offset) baseParams.offset = Number(rest.offset)
if (rest.ancestorFolderId) baseParams.ancestorFolderId = rest.ancestorFolderId
if (rest.fileExtensions) baseParams.fileExtensions = rest.fileExtensions
if (rest.contentType) baseParams.type = rest.contentType
break
case 'update_file':
baseParams.fileId = rest.fileId
if (rest.newName) baseParams.name = rest.newName
if (rest.description !== undefined) baseParams.description = rest.description
if (rest.moveToFolderId) baseParams.parentFolderId = rest.moveToFolderId
if (rest.tags) baseParams.tags = rest.tags
break
case 'sign_create_request':
baseParams.sourceFileIds = rest.sourceFileIds
baseParams.signerEmail = rest.signerEmail
if (rest.signerRole) baseParams.signerRole = rest.signerRole
if (rest.additionalSigners) baseParams.additionalSigners = rest.additionalSigners
if (rest.signParentFolderId) baseParams.parentFolderId = rest.signParentFolderId
if (rest.emailSubject) baseParams.emailSubject = rest.emailSubject
if (rest.emailMessage) baseParams.emailMessage = rest.emailMessage
if (rest.signRequestName) baseParams.name = rest.signRequestName
if (rest.daysValid) baseParams.daysValid = Number(rest.daysValid)
if (rest.areRemindersEnabled !== undefined)
baseParams.areRemindersEnabled = rest.areRemindersEnabled
if (rest.areTextSignaturesEnabled !== undefined)
baseParams.areTextSignaturesEnabled = rest.areTextSignaturesEnabled
if (rest.signatureColor) baseParams.signatureColor = rest.signatureColor
if (rest.redirectUrl) baseParams.redirectUrl = rest.redirectUrl
if (rest.declinedRedirectUrl) baseParams.declinedRedirectUrl = rest.declinedRedirectUrl
if (rest.isDocumentPreparationNeeded !== undefined)
baseParams.isDocumentPreparationNeeded = rest.isDocumentPreparationNeeded
if (rest.externalId) baseParams.externalId = rest.externalId
break
case 'sign_get_request':
case 'sign_cancel_request':
case 'sign_resend_request':
baseParams.signRequestId = rest.signRequestId
break
case 'sign_list_requests':
if (rest.limit) baseParams.limit = Number(rest.limit)
if (rest.marker) baseParams.marker = rest.marker
break
}
return baseParams
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Box OAuth credential' },
file: { type: 'json', description: 'File to upload (canonical param)' },
fileId: { type: 'string', description: 'Box file ID' },
folderId: { type: 'string', description: 'Box folder ID' },
parentFolderId: { type: 'string', description: 'Parent folder ID' },
query: { type: 'string', description: 'Search query' },
sourceFileIds: { type: 'string', description: 'Comma-separated Box file IDs' },
signerEmail: { type: 'string', description: 'Primary signer email address' },
signRequestId: { type: 'string', description: 'Sign request ID' },
},
outputs: {
id: 'string',
name: 'string',
description: 'string',
size: 'number',
sha1: 'string',
createdAt: 'string',
modifiedAt: 'string',
createdBy: 'json',
modifiedBy: 'json',
ownedBy: 'json',
parentId: 'string',
parentName: 'string',
sharedLink: 'json',
tags: 'json',
commentCount: 'number',
file: 'file',
content: 'string',
entries: 'json',
totalCount: 'number',
offset: 'number',
limit: 'number',
results: 'json',
deleted: 'boolean',
message: 'string',
status: 'string',
shortId: 'string',
signers: 'json',
sourceFiles: 'json',
emailSubject: 'string',
emailMessage: 'string',
daysValid: 'number',
autoExpireAt: 'string',
prepareUrl: 'string',
senderEmail: 'string',
signRequests: 'json',
count: 'number',
nextMarker: 'string',
},
}

View File

@@ -0,0 +1,372 @@
import { DocuSignIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
import type { DocuSignResponse } from '@/tools/docusign/types'
export const DocuSignBlock: BlockConfig<DocuSignResponse> = {
type: 'docusign',
name: 'DocuSign',
description: 'Send documents for e-signature via DocuSign',
longDescription:
'Create and send envelopes for e-signature, use templates, check signing status, download signed documents, and manage recipients with DocuSign.',
docsLink: 'https://docs.sim.ai/tools/docusign',
category: 'tools',
bgColor: '#FFFFFF',
icon: DocuSignIcon,
authMode: AuthMode.OAuth,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Send Envelope', id: 'send_envelope' },
{ label: 'Send from Template', id: 'create_from_template' },
{ label: 'Get Envelope', id: 'get_envelope' },
{ label: 'List Envelopes', id: 'list_envelopes' },
{ label: 'Void Envelope', id: 'void_envelope' },
{ label: 'Download Document', id: 'download_document' },
{ label: 'List Templates', id: 'list_templates' },
{ label: 'List Recipients', id: 'list_recipients' },
],
value: () => 'send_envelope',
},
{
id: 'credential',
title: 'DocuSign Account',
type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'docusign',
requiredScopes: getScopesForService('docusign'),
placeholder: 'Select DocuSign account',
required: true,
},
{
id: 'manualCredential',
title: 'DocuSign Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Send Envelope fields
{
id: 'emailSubject',
title: 'Email Subject',
type: 'short-input',
placeholder: 'Please sign this document',
condition: { field: 'operation', value: ['send_envelope', 'create_from_template'] },
required: { field: 'operation', value: 'send_envelope' },
},
{
id: 'emailBody',
title: 'Email Body',
type: 'long-input',
placeholder: 'Optional message to include in the email',
condition: { field: 'operation', value: ['send_envelope', 'create_from_template'] },
mode: 'advanced',
},
{
id: 'signerEmail',
title: 'Signer Email',
type: 'short-input',
placeholder: 'signer@example.com',
condition: { field: 'operation', value: 'send_envelope' },
required: { field: 'operation', value: 'send_envelope' },
},
{
id: 'signerName',
title: 'Signer Name',
type: 'short-input',
placeholder: 'John Doe',
condition: { field: 'operation', value: 'send_envelope' },
required: { field: 'operation', value: 'send_envelope' },
},
{
id: 'uploadDocument',
title: 'Document',
type: 'file-upload',
canonicalParamId: 'documentFile',
placeholder: 'Upload document for signature',
mode: 'basic',
multiple: false,
condition: { field: 'operation', value: 'send_envelope' },
},
{
id: 'documentRef',
title: 'Document',
type: 'short-input',
canonicalParamId: 'documentFile',
placeholder: 'Reference file from another block',
mode: 'advanced',
condition: { field: 'operation', value: 'send_envelope' },
},
{
id: 'ccEmail',
title: 'CC Email',
type: 'short-input',
placeholder: 'cc@example.com',
condition: { field: 'operation', value: 'send_envelope' },
mode: 'advanced',
},
{
id: 'ccName',
title: 'CC Name',
type: 'short-input',
placeholder: 'CC recipient name',
condition: { field: 'operation', value: 'send_envelope' },
mode: 'advanced',
},
{
id: 'envelopeStatus',
title: 'Status',
type: 'dropdown',
options: [
{ label: 'Send Immediately', id: 'sent' },
{ label: 'Save as Draft', id: 'created' },
],
value: () => 'sent',
condition: { field: 'operation', value: ['send_envelope', 'create_from_template'] },
mode: 'advanced',
},
// Send from Template fields
{
id: 'templateId',
title: 'Template ID',
type: 'short-input',
placeholder: 'DocuSign template ID',
condition: { field: 'operation', value: 'create_from_template' },
required: { field: 'operation', value: 'create_from_template' },
},
{
id: 'templateRoles',
title: 'Template Roles',
type: 'long-input',
placeholder: '[{"roleName":"Signer","name":"John Doe","email":"john@example.com"}]',
condition: { field: 'operation', value: 'create_from_template' },
required: { field: 'operation', value: 'create_from_template' },
wandConfig: {
enabled: true,
prompt:
'Generate a JSON array of DocuSign template role objects. Each role needs: roleName (must match the template role name), name (full name), email (email address). Return ONLY the JSON array.',
generationType: 'json-object',
},
},
// Envelope ID field (shared across multiple operations)
{
id: 'envelopeId',
title: 'Envelope ID',
type: 'short-input',
placeholder: 'DocuSign envelope ID',
condition: {
field: 'operation',
value: ['get_envelope', 'void_envelope', 'download_document', 'list_recipients'],
},
required: {
field: 'operation',
value: ['get_envelope', 'void_envelope', 'download_document', 'list_recipients'],
},
},
// Void Envelope fields
{
id: 'voidedReason',
title: 'Void Reason',
type: 'short-input',
placeholder: 'Reason for voiding this envelope',
condition: { field: 'operation', value: 'void_envelope' },
required: { field: 'operation', value: 'void_envelope' },
},
// Download Document fields
{
id: 'documentId',
title: 'Document ID',
type: 'short-input',
placeholder: '"combined" for all docs, or specific document ID',
condition: { field: 'operation', value: 'download_document' },
mode: 'advanced',
},
// List Envelopes filters
{
id: 'fromDate',
title: 'From Date',
type: 'short-input',
placeholder: 'ISO 8601 date (defaults to 30 days ago)',
condition: { field: 'operation', value: 'list_envelopes' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
generationType: 'timestamp',
},
},
{
id: 'toDate',
title: 'To Date',
type: 'short-input',
placeholder: 'ISO 8601 date',
condition: { field: 'operation', value: 'list_envelopes' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
generationType: 'timestamp',
},
},
{
id: 'listEnvelopeStatus',
title: 'Status Filter',
type: 'dropdown',
options: [
{ label: 'All', id: '' },
{ label: 'Created', id: 'created' },
{ label: 'Sent', id: 'sent' },
{ label: 'Delivered', id: 'delivered' },
{ label: 'Completed', id: 'completed' },
{ label: 'Declined', id: 'declined' },
{ label: 'Voided', id: 'voided' },
],
value: () => '',
condition: { field: 'operation', value: 'list_envelopes' },
mode: 'advanced',
},
{
id: 'searchText',
title: 'Search',
type: 'short-input',
placeholder: 'Search envelopes or templates',
condition: { field: 'operation', value: ['list_envelopes', 'list_templates'] },
mode: 'advanced',
},
{
id: 'count',
title: 'Max Results',
type: 'short-input',
placeholder: '25',
condition: { field: 'operation', value: ['list_envelopes', 'list_templates'] },
mode: 'advanced',
},
],
tools: {
access: [
'docusign_send_envelope',
'docusign_create_from_template',
'docusign_get_envelope',
'docusign_list_envelopes',
'docusign_void_envelope',
'docusign_download_document',
'docusign_list_templates',
'docusign_list_recipients',
],
config: {
tool: (params) => `docusign_${params.operation}`,
params: (params) => {
const { oauthCredential, operation, documentFile, listEnvelopeStatus, ...rest } = params
const cleanParams: Record<string, unknown> = {
oauthCredential,
}
const file = normalizeFileInput(documentFile, { single: true })
if (file) {
cleanParams.file = file
}
if (listEnvelopeStatus && operation === 'list_envelopes') {
cleanParams.envelopeStatus = listEnvelopeStatus
}
if (operation === 'create_from_template') {
cleanParams.status = rest.envelopeStatus
} else if (operation === 'send_envelope') {
cleanParams.status = rest.envelopeStatus
}
const excludeKeys = ['envelopeStatus']
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined && value !== null && value !== '' && !excludeKeys.includes(key)) {
cleanParams[key] = value
}
}
return cleanParams
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'DocuSign access token' },
emailSubject: { type: 'string', description: 'Email subject for the envelope' },
emailBody: { type: 'string', description: 'Email body message' },
signerEmail: { type: 'string', description: 'Signer email address' },
signerName: { type: 'string', description: 'Signer full name' },
documentFile: { type: 'string', description: 'Document file for signature' },
ccEmail: { type: 'string', description: 'CC recipient email' },
ccName: { type: 'string', description: 'CC recipient name' },
templateId: { type: 'string', description: 'DocuSign template ID' },
templateRoles: { type: 'string', description: 'JSON array of template roles' },
envelopeId: { type: 'string', description: 'Envelope ID' },
voidedReason: { type: 'string', description: 'Reason for voiding' },
documentId: { type: 'string', description: 'Document ID to download' },
fromDate: { type: 'string', description: 'Start date filter' },
toDate: { type: 'string', description: 'End date filter' },
searchText: { type: 'string', description: 'Search text filter' },
count: { type: 'string', description: 'Max results to return' },
},
outputs: {
envelopeId: { type: 'string', description: 'Envelope ID' },
status: {
type: 'string',
description: 'Envelope status (created, sent, delivered, completed, declined, voided)',
},
statusDateTime: { type: 'string', description: 'ISO 8601 datetime of status change' },
uri: { type: 'string', description: 'Envelope URI path' },
emailSubject: { type: 'string', description: 'Envelope email subject' },
sentDateTime: { type: 'string', description: 'ISO 8601 datetime when envelope was sent' },
completedDateTime: { type: 'string', description: 'ISO 8601 datetime when signing completed' },
createdDateTime: { type: 'string', description: 'ISO 8601 datetime when envelope was created' },
statusChangedDateTime: {
type: 'string',
description: 'ISO 8601 datetime of last status change',
},
voidedReason: { type: 'string', description: 'Reason the envelope was voided' },
signerCount: { type: 'number', description: 'Number of signers on the envelope' },
documentCount: { type: 'number', description: 'Number of documents in the envelope' },
envelopes: {
type: 'json',
description:
'Array of envelopes (envelopeId, status, emailSubject, sentDateTime, completedDateTime, createdDateTime, statusChangedDateTime)',
},
templates: {
type: 'json',
description:
'Array of templates (templateId, name, description, shared, created, lastModified)',
},
signers: {
type: 'json',
description:
'Array of signer recipients (recipientId, name, email, status, signedDateTime, deliveredDateTime)',
},
carbonCopies: {
type: 'json',
description: 'Array of CC recipients (recipientId, name, email, status)',
},
base64Content: { type: 'string', description: 'Base64-encoded document content' },
mimeType: { type: 'string', description: 'Document MIME type' },
fileName: { type: 'string', description: 'Document file name' },
totalSetSize: { type: 'number', description: 'Total matching results' },
resultSetSize: { type: 'number', description: 'Results returned in this response' },
},
}

View File

@@ -0,0 +1,440 @@
import { WorkdayIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
export const WorkdayBlock: BlockConfig = {
type: 'workday',
name: 'Workday',
description: 'Manage workers, hiring, onboarding, and HR operations in Workday',
longDescription:
'Integrate Workday HRIS into your workflow. Create pre-hires, hire employees, manage worker profiles, assign onboarding plans, handle job changes, retrieve compensation data, and process terminations.',
docsLink: 'https://docs.sim.ai/tools/workday',
category: 'tools',
bgColor: '#F5F0EB',
icon: WorkdayIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Get Worker', id: 'get_worker' },
{ label: 'List Workers', id: 'list_workers' },
{ label: 'Create Pre-Hire', id: 'create_prehire' },
{ label: 'Hire Employee', id: 'hire_employee' },
{ label: 'Update Worker', id: 'update_worker' },
{ label: 'Assign Onboarding Plan', id: 'assign_onboarding' },
{ label: 'Get Organizations', id: 'get_organizations' },
{ label: 'Change Job', id: 'change_job' },
{ label: 'Get Compensation', id: 'get_compensation' },
{ label: 'Terminate Worker', id: 'terminate_worker' },
],
value: () => 'get_worker',
},
{
id: 'tenantUrl',
title: 'Tenant URL',
type: 'short-input',
placeholder: 'https://wd2-impl-services1.workday.com',
required: true,
description: 'Your Workday instance URL (e.g., https://wd2-impl-services1.workday.com)',
},
{
id: 'tenant',
title: 'Tenant Name',
type: 'short-input',
placeholder: 'mycompany',
required: true,
description: 'Workday tenant identifier',
},
{
id: 'username',
title: 'Username',
type: 'short-input',
placeholder: 'ISU username',
required: true,
description: 'Integration System User username',
},
{
id: 'password',
title: 'Password',
type: 'short-input',
placeholder: 'ISU password',
password: true,
required: true,
description: 'Integration System User password',
},
// Get Worker
{
id: 'workerId',
title: 'Worker ID',
type: 'short-input',
placeholder: 'e.g., 3aa5550b7fe348b98d7b5741afc65534',
condition: {
field: 'operation',
value: [
'get_worker',
'update_worker',
'assign_onboarding',
'change_job',
'get_compensation',
'terminate_worker',
],
},
required: {
field: 'operation',
value: [
'get_worker',
'update_worker',
'assign_onboarding',
'change_job',
'get_compensation',
'terminate_worker',
],
},
},
// List Workers
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: '20',
condition: { field: 'operation', value: ['list_workers', 'get_organizations'] },
mode: 'advanced',
},
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: ['list_workers', 'get_organizations'] },
mode: 'advanced',
},
// Create Pre-Hire
{
id: 'legalName',
title: 'Legal Name',
type: 'short-input',
placeholder: 'e.g., Jane Doe',
condition: { field: 'operation', value: 'create_prehire' },
required: { field: 'operation', value: 'create_prehire' },
},
{
id: 'email',
title: 'Email',
type: 'short-input',
placeholder: 'jane.doe@company.com',
condition: { field: 'operation', value: 'create_prehire' },
},
{
id: 'phoneNumber',
title: 'Phone Number',
type: 'short-input',
placeholder: '+1-555-0100',
condition: { field: 'operation', value: 'create_prehire' },
mode: 'advanced',
},
{
id: 'address',
title: 'Address',
type: 'short-input',
placeholder: '123 Main St, City, State',
condition: { field: 'operation', value: 'create_prehire' },
mode: 'advanced',
},
{
id: 'countryCode',
title: 'Country Code',
type: 'short-input',
placeholder: 'US',
condition: { field: 'operation', value: 'create_prehire' },
mode: 'advanced',
description: 'ISO 3166-1 Alpha-2 country code (defaults to US)',
},
// Hire Employee
{
id: 'preHireId',
title: 'Pre-Hire ID',
type: 'short-input',
placeholder: 'Pre-hire record ID',
condition: { field: 'operation', value: 'hire_employee' },
required: { field: 'operation', value: 'hire_employee' },
},
{
id: 'positionId',
title: 'Position ID',
type: 'short-input',
placeholder: 'Position to assign',
condition: { field: 'operation', value: ['hire_employee', 'change_job'] },
required: { field: 'operation', value: ['hire_employee'] },
},
{
id: 'hireDate',
title: 'Hire Date',
type: 'short-input',
placeholder: 'YYYY-MM-DD',
condition: { field: 'operation', value: 'hire_employee' },
required: { field: 'operation', value: 'hire_employee' },
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 date (YYYY-MM-DD). Return ONLY the date string.',
generationType: 'timestamp',
},
},
{
id: 'jobProfileId',
title: 'Job Profile ID',
type: 'short-input',
placeholder: 'Job profile ID',
condition: { field: 'operation', value: 'change_job' },
mode: 'advanced',
},
{
id: 'locationId',
title: 'Location ID',
type: 'short-input',
placeholder: 'Work location ID',
condition: { field: 'operation', value: 'change_job' },
mode: 'advanced',
},
{
id: 'supervisoryOrgId',
title: 'Supervisory Organization ID',
type: 'short-input',
placeholder: 'Target supervisory organization ID',
condition: { field: 'operation', value: 'change_job' },
mode: 'advanced',
},
{
id: 'employeeType',
title: 'Employee Type',
type: 'dropdown',
options: [
{ label: 'Regular', id: 'Regular' },
{ label: 'Temporary', id: 'Temporary' },
{ label: 'Contractor', id: 'Contractor' },
],
value: () => 'Regular',
condition: { field: 'operation', value: 'hire_employee' },
mode: 'advanced',
},
// Update Worker
{
id: 'fields',
title: 'Fields (JSON)',
type: 'code',
language: 'json',
placeholder:
'{\n "businessTitle": "Senior Engineer",\n "primaryWorkEmail": "new@company.com"\n}',
condition: { field: 'operation', value: 'update_worker' },
required: { field: 'operation', value: 'update_worker' },
wandConfig: {
enabled: true,
maintainHistory: true,
prompt: `Generate a Workday worker update payload as JSON.
### COMMON FIELDS
- businessTitle: Job title string
- primaryWorkEmail: Work email address
- primaryWorkPhone: Work phone number
- managerReference: Manager worker ID
### RULES
- Output ONLY valid JSON starting with { and ending with }
- Include only fields that need updating
### EXAMPLE
User: "Update title to Senior Engineer"
Output: {"businessTitle": "Senior Engineer"}`,
generationType: 'json-object',
},
},
// Assign Onboarding
{
id: 'onboardingPlanId',
title: 'Onboarding Plan ID',
type: 'short-input',
placeholder: 'Plan ID to assign',
condition: { field: 'operation', value: 'assign_onboarding' },
required: { field: 'operation', value: 'assign_onboarding' },
},
{
id: 'actionEventId',
title: 'Action Event ID',
type: 'short-input',
placeholder: 'Hiring event ID that enables onboarding',
condition: { field: 'operation', value: 'assign_onboarding' },
required: { field: 'operation', value: 'assign_onboarding' },
},
// Get Organizations
{
id: 'orgType',
title: 'Organization Type',
type: 'dropdown',
options: [
{ label: 'All Types', id: '' },
{ label: 'Supervisory', id: 'Supervisory' },
{ label: 'Cost Center', id: 'Cost_Center' },
{ label: 'Company', id: 'Company' },
{ label: 'Region', id: 'Region' },
],
value: () => '',
condition: { field: 'operation', value: 'get_organizations' },
},
// Change Job
{
id: 'effectiveDate',
title: 'Effective Date',
type: 'short-input',
placeholder: 'YYYY-MM-DD',
condition: { field: 'operation', value: 'change_job' },
required: { field: 'operation', value: 'change_job' },
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 date (YYYY-MM-DD). Return ONLY the date string.',
generationType: 'timestamp',
},
},
{
id: 'reason',
title: 'Reason',
type: 'short-input',
placeholder: 'e.g., Promotion, Transfer',
condition: { field: 'operation', value: ['change_job', 'terminate_worker'] },
required: { field: 'operation', value: ['change_job', 'terminate_worker'] },
},
// Terminate Worker
{
id: 'terminationDate',
title: 'Termination Date',
type: 'short-input',
placeholder: 'YYYY-MM-DD',
condition: { field: 'operation', value: 'terminate_worker' },
required: { field: 'operation', value: 'terminate_worker' },
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 date (YYYY-MM-DD). Return ONLY the date string.',
generationType: 'timestamp',
},
},
{
id: 'notificationDate',
title: 'Notification Date',
type: 'short-input',
placeholder: 'YYYY-MM-DD',
condition: { field: 'operation', value: 'terminate_worker' },
mode: 'advanced',
},
{
id: 'lastDayOfWork',
title: 'Last Day of Work',
type: 'short-input',
placeholder: 'YYYY-MM-DD (defaults to termination date)',
condition: { field: 'operation', value: 'terminate_worker' },
mode: 'advanced',
},
],
tools: {
access: [
'workday_get_worker',
'workday_list_workers',
'workday_create_prehire',
'workday_hire_employee',
'workday_update_worker',
'workday_assign_onboarding',
'workday_get_organizations',
'workday_change_job',
'workday_get_compensation',
'workday_terminate_worker',
],
config: {
tool: (params) => `workday_${params.operation}`,
params: (params) => {
const { operation, orgType, fields, jobProfileId, locationId, supervisoryOrgId, ...rest } =
params
if (rest.limit != null && rest.limit !== '') rest.limit = Number(rest.limit)
if (rest.offset != null && rest.offset !== '') rest.offset = Number(rest.offset)
if (orgType) rest.type = orgType
if (operation === 'change_job') {
if (rest.positionId) {
rest.newPositionId = rest.positionId
rest.positionId = undefined
}
if (jobProfileId) rest.newJobProfileId = jobProfileId
if (locationId) rest.newLocationId = locationId
if (supervisoryOrgId) rest.newSupervisoryOrgId = supervisoryOrgId
}
if (fields && operation === 'update_worker') {
try {
const parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields
return { ...rest, fields: parsedFields }
} catch {
throw new Error('Invalid JSON in Fields block')
}
}
return rest
},
},
},
inputs: {
operation: { type: 'string', description: 'Workday operation to perform' },
tenantUrl: { type: 'string', description: 'Workday instance URL' },
tenant: { type: 'string', description: 'Workday tenant name' },
username: { type: 'string', description: 'ISU username' },
password: { type: 'string', description: 'ISU password' },
workerId: { type: 'string', description: 'Worker ID' },
limit: { type: 'number', description: 'Result limit' },
offset: { type: 'number', description: 'Pagination offset' },
legalName: { type: 'string', description: 'Legal name for pre-hire' },
email: { type: 'string', description: 'Email address' },
phoneNumber: { type: 'string', description: 'Phone number' },
address: { type: 'string', description: 'Address' },
countryCode: { type: 'string', description: 'ISO 3166-1 Alpha-2 country code' },
preHireId: { type: 'string', description: 'Pre-hire record ID' },
positionId: { type: 'string', description: 'Position ID' },
hireDate: { type: 'string', description: 'Hire date (YYYY-MM-DD)' },
jobProfileId: { type: 'string', description: 'Job profile ID' },
locationId: { type: 'string', description: 'Location ID' },
supervisoryOrgId: { type: 'string', description: 'Target supervisory organization ID' },
employeeType: { type: 'string', description: 'Employee type' },
fields: { type: 'json', description: 'Fields to update' },
onboardingPlanId: { type: 'string', description: 'Onboarding plan ID' },
actionEventId: { type: 'string', description: 'Action event ID for onboarding' },
orgType: { type: 'string', description: 'Organization type filter' },
effectiveDate: { type: 'string', description: 'Effective date (YYYY-MM-DD)' },
reason: { type: 'string', description: 'Reason for change or termination' },
terminationDate: { type: 'string', description: 'Termination date (YYYY-MM-DD)' },
notificationDate: { type: 'string', description: 'Notification date' },
lastDayOfWork: { type: 'string', description: 'Last day of work' },
},
outputs: {
worker: { type: 'json', description: 'Worker profile data' },
workers: { type: 'json', description: 'Array of worker profiles' },
total: { type: 'number', description: 'Total count of results' },
preHireId: { type: 'string', description: 'Created pre-hire ID' },
descriptor: { type: 'string', description: 'Display name of pre-hire' },
workerId: { type: 'string', description: 'Worker ID' },
employeeId: { type: 'string', description: 'Employee ID' },
hireDate: { type: 'string', description: 'Hire date' },
assignmentId: { type: 'string', description: 'Onboarding assignment ID' },
planId: { type: 'string', description: 'Onboarding plan ID' },
organizations: { type: 'json', description: 'Array of organizations' },
eventId: { type: 'string', description: 'Event ID for staffing changes' },
effectiveDate: { type: 'string', description: 'Effective date of change' },
compensationPlans: { type: 'json', description: 'Compensation plan details' },
terminationDate: { type: 'string', description: 'Termination date' },
},
}

View File

@@ -13,6 +13,7 @@ import { ArxivBlock } from '@/blocks/blocks/arxiv'
import { AsanaBlock } from '@/blocks/blocks/asana'
import { AshbyBlock } from '@/blocks/blocks/ashby'
import { AttioBlock } from '@/blocks/blocks/attio'
import { BoxBlock } from '@/blocks/blocks/box'
import { BrandfetchBlock } from '@/blocks/blocks/brandfetch'
import { BrowserUseBlock } from '@/blocks/blocks/browser_use'
import { CalComBlock } from '@/blocks/blocks/calcom'
@@ -29,6 +30,7 @@ import { DatabricksBlock } from '@/blocks/blocks/databricks'
import { DatadogBlock } from '@/blocks/blocks/datadog'
import { DevinBlock } from '@/blocks/blocks/devin'
import { DiscordBlock } from '@/blocks/blocks/discord'
import { DocuSignBlock } from '@/blocks/blocks/docusign'
import { DropboxBlock } from '@/blocks/blocks/dropbox'
import { DSPyBlock } from '@/blocks/blocks/dspy'
import { DubBlock } from '@/blocks/blocks/dub'
@@ -188,6 +190,7 @@ import { WebhookRequestBlock } from '@/blocks/blocks/webhook_request'
import { WhatsAppBlock } from '@/blocks/blocks/whatsapp'
import { WikipediaBlock } from '@/blocks/blocks/wikipedia'
import { WordPressBlock } from '@/blocks/blocks/wordpress'
import { WorkdayBlock } from '@/blocks/blocks/workday'
import { WorkflowBlock } from '@/blocks/blocks/workflow'
import { WorkflowInputBlock } from '@/blocks/blocks/workflow_input'
import { XBlock } from '@/blocks/blocks/x'
@@ -215,6 +218,7 @@ export const registry: Record<string, BlockConfig> = {
ashby: AshbyBlock,
attio: AttioBlock,
brandfetch: BrandfetchBlock,
box: BoxBlock,
browser_use: BrowserUseBlock,
calcom: CalComBlock,
calendly: CalendlyBlock,
@@ -232,6 +236,7 @@ export const registry: Record<string, BlockConfig> = {
datadog: DatadogBlock,
devin: DevinBlock,
discord: DiscordBlock,
docusign: DocuSignBlock,
dropbox: DropboxBlock,
dspy: DSPyBlock,
dub: DubBlock,
@@ -408,6 +413,7 @@ export const registry: Record<string, BlockConfig> = {
whatsapp: WhatsAppBlock,
wikipedia: WikipediaBlock,
wordpress: WordPressBlock,
workday: WorkdayBlock,
workflow: WorkflowBlock,
workflow_input: WorkflowInputBlock,
x: XBlock,

View File

@@ -124,6 +124,34 @@ export function NoteIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function WorkdayIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const clipId = `workday_clip_${id}`
return (
<svg {...props} viewBox='0 0 64 64' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g clipPath={`url(#${clipId})`} transform='matrix(0.53333333,0,0,0.53333333,-124.63685,-16)'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='m 251.21,88.7755 h 8.224 c 1.166,0 2.178,0.7836 2.444,1.8924 l 11.057,44.6751 c 0.152,0.002 12.182,-44.6393 12.182,-44.6393 0.306,-1.1361 1.36,-1.9282 2.566,-1.9282 h 12.74 c 1.144,0 2.144,0.7515 2.435,1.8296 l 12.118,44.9289 c 0.448,-0.282 11.147,-44.8661 11.147,-44.8661 0.267,-1.1088 1.279,-1.8924 2.444,-1.8924 h 8.219 c 1.649,0 2.854,1.5192 2.437,3.0742 l -15.08,56.3173 c -0.286,1.072 -1.272,1.823 -2.406,1.833 l -12.438,-0.019 c -1.142,-0.002 -2.137,-0.744 -2.429,-1.819 -2.126,-7.805 -12.605,-47.277 -12.605,-47.277 0,0 -11.008,39.471 -13.133,47.277 -0.293,1.075 -1.288,1.817 -2.429,1.819 L 266.264,150 c -1.133,-0.01 -2.119,-0.761 -2.406,-1.833 L 248.777,91.8438 c -0.416,-1.5524 0.786,-3.0683 2.433,-3.0683 z'
fill='#005cb9'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='m 333.324,72.2449 c 0.531,0 1.071,-0.0723 1.608,-0.2234 3.18,-0.8968 5.039,-4.2303 4.153,-7.446 -0.129,-0.4673 -0.265,-0.9327 -0.408,-1.3936 C 332.529,43.3349 314.569,30 293.987,30 c -20.557,0 -38.51,13.3133 -44.673,33.1281 -0.136,0.4355 -0.267,0.8782 -0.391,1.3232 -0.902,3.2119 0.943,6.5541 4.12,7.4645 3.173,0.9112 6.48,-0.9547 7.381,-4.1666 0.094,-0.3322 0.19,-0.6616 0.292,-0.9892 4.591,-14.7582 17.961,-24.6707 33.271,-24.6707 15.329,0 28.704,9.9284 33.281,24.7063 0.105,0.3397 0.206,0.682 0.301,1.0263 0.737,2.6726 3.139,4.423 5.755,4.423 z'
fill='#f38b00'
/>
</g>
<defs>
<clipPath id={clipId}>
<path d='M 354,30 H 234 v 120 h 120 z' fill='#ffffff' />
</clipPath>
</defs>
</svg>
)
}
export function WorkflowIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -1146,6 +1174,25 @@ export function DevinIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function DocuSignIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 1547 1549' xmlns='http://www.w3.org/2000/svg'>
<path
d='m1113.4 1114.9v395.6c0 20.8-16.7 37.6-37.5 37.6h-1038.4c-20.7 0-37.5-16.8-37.5-37.6v-1039c0-20.7 16.8-37.5 37.5-37.5h394.3v643.4c0 20.7 16.8 37.5 37.5 37.5z'
fill='#4c00ff'
/>
<path
d='m1546 557.1c0 332.4-193.9 557-432.6 557.8v-418.8c0-12-4.8-24-13.5-31.9l-217.1-217.4c-8.8-8.8-20-13.6-32-13.6h-418.2v-394.8c0-20.8 16.8-37.6 37.5-37.6h585.1c277.7-0.8 490.8 223 490.8 556.3z'
fill='#ff5252'
/>
<path
d='m1099.9 663.4c8.7 8.7 13.5 19.9 13.5 31.9v418.8h-643.3c-20.7 0-37.5-16.8-37.5-37.5v-643.4h418.2c12 0 24 4.8 32 13.6z'
fill='#000000'
/>
</svg>
)
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -4569,11 +4616,17 @@ export function ShopifyIcon(props: SVGProps<SVGSVGElement>) {
export function BoxCompanyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 41 22'>
<path
d='M39.7 19.2c.5.7.4 1.6-.2 2.1-.7.5-1.7.4-2.2-.2l-3.5-4.5-3.4 4.4c-.5.7-1.5.7-2.2.2-.7-.5-.8-1.4-.3-2.1l4-5.2-4-5.2c-.5-.7-.3-1.7.3-2.2.7-.5 1.7-.3 2.2.3l3.4 4.5L37.3 7c.5-.7 1.4-.8 2.2-.3.7.5.7 1.5.2 2.2L35.8 14l3.9 5.2zm-18.2-.6c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c-.1 2.6-2.2 4.6-4.7 4.6zm-13.8 0c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c0 2.6-2.1 4.6-4.7 4.6zM21.5 6.4c-2.9 0-5.5 1.6-6.8 4-1.3-2.4-3.9-4-6.9-4-1.8 0-3.4.6-4.7 1.5V1.5C3.1.7 2.4 0 1.6 0 .7 0 0 .7 0 1.5v12.6c.1 4.2 3.5 7.5 7.7 7.5 3 0 5.6-1.7 6.9-4.1 1.3 2.4 3.9 4.1 6.8 4.1 4.3 0 7.8-3.4 7.8-7.7.1-4.1-3.4-7.5-7.7-7.5z'
fill='currentColor'
/>
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
width='2500'
height='1379'
viewBox='0 0 444.893 245.414'
>
<g fill='#0075C9'>
<path d='M239.038 72.43c-33.081 0-61.806 18.6-76.322 45.904-14.516-27.305-43.24-45.902-76.32-45.902-19.443 0-37.385 6.424-51.821 17.266V16.925h-.008C34.365 7.547 26.713 0 17.286 0 7.858 0 .208 7.547.008 16.925H0v143.333h.036c.768 47.051 39.125 84.967 86.359 84.967 33.08 0 61.805-18.603 76.32-45.908 14.517 27.307 43.241 45.906 76.321 45.906 47.715 0 86.396-38.684 86.396-86.396.001-47.718-38.682-86.397-86.394-86.397zM86.395 210.648c-28.621 0-51.821-23.201-51.821-51.82 0-28.623 23.201-51.823 51.821-51.823 28.621 0 51.822 23.2 51.822 51.823 0 28.619-23.201 51.82-51.822 51.82zm152.643 0c-28.622 0-51.821-23.201-51.821-51.822 0-28.623 23.2-51.821 51.821-51.821 28.619 0 51.822 23.198 51.822 51.821-.001 28.621-23.203 51.822-51.822 51.822z' />
<path d='M441.651 218.033l-44.246-59.143 44.246-59.144-.008-.007c5.473-7.62 3.887-18.249-3.652-23.913-7.537-5.658-18.187-4.221-23.98 3.157l-.004-.002-38.188 51.047-38.188-51.047-.006.009c-5.793-7.385-16.441-8.822-23.981-3.16-7.539 5.664-9.125 16.293-3.649 23.911l-.008.005 44.245 59.144-44.245 59.143.008.005c-5.477 7.62-3.89 18.247 3.649 23.909 7.54 5.664 18.188 4.225 23.981-3.155l.006.007 38.188-51.049 38.188 51.049.004-.002c5.794 7.377 16.443 8.814 23.98 3.154 7.539-5.662 9.125-16.291 3.652-23.91l.008-.008z' />
</g>
</svg>
)
}

View File

@@ -92,6 +92,57 @@ describe('DAGBuilder disabled subflow validation', () => {
// Should not throw - loop is effectively disabled since all inner blocks are disabled
expect(() => builder.build(workflow)).not.toThrow()
})
it('does not mutate serialized loop config nodes during DAG build', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('loop-1', BlockType.LOOP),
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
],
connections: [{ source: 'start', target: 'loop-1' }],
loops: {
'loop-1': {
id: 'loop-1',
nodes: ['inner-block'],
iterations: 3,
},
},
parallels: {},
}
const builder = new DAGBuilder()
builder.build(workflow)
expect(workflow.loops?.['loop-1']?.nodes).toEqual(['inner-block'])
})
it('does not mutate serialized parallel config nodes during DAG build', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('parallel-1', BlockType.PARALLEL),
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
],
connections: [{ source: 'start', target: 'parallel-1' }],
loops: {},
parallels: {
'parallel-1': {
id: 'parallel-1',
nodes: ['inner-block'],
count: 2,
parallelType: 'count',
},
},
}
const builder = new DAGBuilder()
builder.build(workflow)
expect(workflow.parallels?.['parallel-1']?.nodes).toEqual(['inner-block'])
})
})
describe('DAGBuilder nested parallel support', () => {

View File

@@ -113,13 +113,19 @@ export class DAGBuilder {
private initializeConfigs(workflow: SerializedWorkflow, dag: DAG): void {
if (workflow.loops) {
for (const [loopId, loopConfig] of Object.entries(workflow.loops)) {
dag.loopConfigs.set(loopId, loopConfig)
dag.loopConfigs.set(loopId, {
...loopConfig,
nodes: [...(loopConfig.nodes ?? [])],
})
}
}
if (workflow.parallels) {
for (const [parallelId, parallelConfig] of Object.entries(workflow.parallels)) {
dag.parallelConfigs.set(parallelId, parallelConfig)
dag.parallelConfigs.set(parallelId, {
...parallelConfig,
nodes: [...(parallelConfig.nodes ?? [])],
})
}
}
}
@@ -150,9 +156,7 @@ export class DAGBuilder {
if (!sentinelStartNode) return
if (!nodes || nodes.length === 0) {
throw new Error(
`${type} has no blocks inside. Add at least one block to the ${type.toLowerCase()}.`
)
return
}
const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) =>

View File

@@ -8,24 +8,21 @@ const logger = createLogger('LoopConstructor')
export class LoopConstructor {
execute(dag: DAG, reachableBlocks: Set<string>): void {
for (const [loopId, loopConfig] of dag.loopConfigs) {
const loopNodes = loopConfig.nodes
if (loopNodes.length === 0) {
if (!reachableBlocks.has(loopId)) {
continue
}
if (!this.hasReachableNodes(loopNodes, reachableBlocks)) {
continue
const loopNodes = loopConfig.nodes
const hasReachableChildren = loopNodes.some((nodeId) => reachableBlocks.has(nodeId))
if (!hasReachableChildren) {
loopConfig.nodes = []
}
this.createSentinelPair(dag, loopId)
}
}
private hasReachableNodes(loopNodes: string[], reachableBlocks: Set<string>): boolean {
return loopNodes.some((nodeId) => reachableBlocks.has(nodeId))
}
private createSentinelPair(dag: DAG, loopId: string): void {
const startId = buildSentinelStartId(loopId)
const endId = buildSentinelEndId(loopId)

View File

@@ -11,24 +11,21 @@ const logger = createLogger('ParallelConstructor')
export class ParallelConstructor {
execute(dag: DAG, reachableBlocks: Set<string>): void {
for (const [parallelId, parallelConfig] of dag.parallelConfigs) {
const parallelNodes = parallelConfig.nodes
if (parallelNodes.length === 0) {
if (!reachableBlocks.has(parallelId)) {
continue
}
if (!this.hasReachableNodes(parallelNodes, reachableBlocks)) {
continue
const parallelNodes = parallelConfig.nodes
const hasReachableChildren = parallelNodes.some((nodeId) => reachableBlocks.has(nodeId))
if (!hasReachableChildren) {
parallelConfig.nodes = []
}
this.createSentinelPair(dag, parallelId)
}
}
private hasReachableNodes(parallelNodes: string[], reachableBlocks: Set<string>): boolean {
return parallelNodes.some((nodeId) => reachableBlocks.has(nodeId))
}
private createSentinelPair(dag: DAG, parallelId: string): void {
const startId = buildParallelSentinelStartId(parallelId)
const endId = buildParallelSentinelEndId(parallelId)

View File

@@ -56,6 +56,29 @@ export class LoopOrchestrator {
if (!loopConfig) {
throw new Error(`Loop config not found: ${loopId}`)
}
if (loopConfig.nodes.length === 0) {
const errorMessage =
'Loop has no executable blocks inside. Add or enable at least one block in the loop.'
const loopType = loopConfig.loopType || 'for'
logger.error(errorMessage, { loopId })
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {})
const errorScope: LoopScope = {
iteration: 0,
maxIterations: 0,
loopType,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
condition: 'false',
validationError: errorMessage,
}
if (!ctx.loopExecutions) {
ctx.loopExecutions = new Map()
}
ctx.loopExecutions.set(loopId, errorScope)
throw new Error(errorMessage)
}
const scope: LoopScope = {
iteration: 0,
currentIterationOutputs: new Map(),
@@ -93,6 +116,24 @@ export class LoopOrchestrator {
case 'forEach': {
scope.loopType = 'forEach'
if (
loopConfig.forEachItems === undefined ||
loopConfig.forEachItems === null ||
loopConfig.forEachItems === ''
) {
const errorMessage =
'ForEach loop collection is empty. Provide an array or a reference that resolves to a collection.'
logger.error(errorMessage, { loopId })
await this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {
forEachItems: loopConfig.forEachItems,
})
scope.items = []
scope.maxIterations = 0
scope.validationError = errorMessage
scope.condition = buildLoopIndexCondition(0)
ctx.loopExecutions?.set(loopId, scope)
throw new Error(errorMessage)
}
let items: any[]
try {
items = resolveArrayInput(ctx, loopConfig.forEachItems, this.resolver)

View File

@@ -57,6 +57,15 @@ export class ParallelOrchestrator {
throw new Error(`Parallel config not found: ${parallelId}`)
}
if (terminalNodesCount === 0 || parallelConfig.nodes.length === 0) {
const errorMessage =
'Parallel has no executable blocks inside. Add or enable at least one block in the parallel.'
logger.error(errorMessage, { parallelId })
await this.addParallelErrorLog(ctx, parallelId, errorMessage, {})
this.setErrorScope(ctx, parallelId, errorMessage)
throw new Error(errorMessage)
}
let items: any[] | undefined
let branchCount: number
let isEmpty = false
@@ -67,7 +76,10 @@ export class ParallelOrchestrator {
items = resolved.items
isEmpty = resolved.isEmpty ?? false
} catch (error) {
const errorMessage = `Parallel Items did not resolve: ${error instanceof Error ? error.message : String(error)}`
const baseErrorMessage = error instanceof Error ? error.message : String(error)
const errorMessage = baseErrorMessage.startsWith('Parallel collection distribution is empty')
? baseErrorMessage
: `Parallel Items did not resolve: ${baseErrorMessage}`
logger.error(errorMessage, { parallelId, distribution: parallelConfig.distribution })
await this.addParallelErrorLog(ctx, parallelId, errorMessage, {
distribution: parallelConfig.distribution,
@@ -258,7 +270,9 @@ export class ParallelOrchestrator {
config.distribution === null ||
config.distribution === ''
) {
return []
throw new Error(
'Parallel collection distribution is empty. Provide an array or a reference that resolves to a collection.'
)
}
return resolveArrayInput(ctx, config.distribution, this.resolver)
}

View File

@@ -211,6 +211,16 @@ export const auth = betterAuth({
modifiedAccount.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
// Box token response does not include a scope field, so Better Auth
// stores nothing. Populate it from the requested scopes so the
// credential-selector can verify permissions.
if (account.providerId === 'box' && !account.scope) {
const requestedScopes = getCanonicalScopesForProvider('box')
if (requestedScopes.length > 0) {
modifiedAccount.scope = requestedScopes.join(' ')
}
}
return { data: modifiedAccount }
},
after: async (account) => {
@@ -478,6 +488,7 @@ export const auth = betterAuth({
'sharepoint',
'jira',
'airtable',
'box',
'dropbox',
'salesforce',
'wealthbox',
@@ -488,6 +499,7 @@ export const auth = betterAuth({
'shopify',
'trello',
'calcom',
'docusign',
...SSO_TRUSTED_PROVIDERS,
],
},
@@ -2176,6 +2188,51 @@ export const auth = betterAuth({
},
},
{
providerId: 'box',
clientId: env.BOX_CLIENT_ID as string,
clientSecret: env.BOX_CLIENT_SECRET as string,
authorizationUrl: 'https://account.box.com/api/oauth2/authorize',
tokenUrl: 'https://api.box.com/oauth2/token',
scopes: getCanonicalScopesForProvider('box'),
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/box`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://api.box.com/2.0/users/me', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Box API error:', {
status: response.status,
statusText: response.statusText,
body: errorText,
})
throw new Error(`Box API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
return {
id: `${data.id}-${crypto.randomUUID()}`,
email: data.login,
name: data.name || data.login,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: data.avatar_url || undefined,
}
} catch (error) {
logger.error('Error in Box getUserInfo:', error)
throw error
}
},
},
{
providerId: 'dropbox',
clientId: env.DROPBOX_CLIENT_ID as string,
@@ -2593,6 +2650,64 @@ export const auth = betterAuth({
},
},
// DocuSign provider
{
providerId: 'docusign',
clientId: env.DOCUSIGN_CLIENT_ID as string,
clientSecret: env.DOCUSIGN_CLIENT_SECRET as string,
authorizationUrl: 'https://account-d.docusign.com/oauth/auth',
tokenUrl: 'https://account-d.docusign.com/oauth/token',
userInfoUrl: 'https://account-d.docusign.com/oauth/userinfo',
scopes: getCanonicalScopesForProvider('docusign'),
responseType: 'code',
accessType: 'offline',
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/docusign`,
getUserInfo: async (tokens) => {
try {
logger.info('Fetching DocuSign user profile')
const response = await fetch('https://account-d.docusign.com/oauth/userinfo', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
})
if (!response.ok) {
await response.text().catch(() => {})
logger.error('Failed to fetch DocuSign user info', {
status: response.status,
statusText: response.statusText,
})
throw new Error('Failed to fetch user info')
}
const data = await response.json()
const accounts = data.accounts ?? []
const defaultAccount =
accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0]
const accountName = defaultAccount?.account_name || 'DocuSign Account'
if (data.scope) {
tokens.scopes = data.scope.split(/\s+/).filter(Boolean)
}
return {
id: `${data.sub}-${crypto.randomUUID()}`,
name: data.name || accountName,
email: data.email || `${data.sub}@docusign.com`,
emailVerified: true,
image: undefined,
createdAt: new Date(),
updatedAt: new Date(),
}
} catch (error) {
logger.error('Error in DocuSign getUserInfo:', { error })
return null
}
},
},
// Cal.com provider
{
providerId: 'calcom',

View File

@@ -503,22 +503,37 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
wasBlocked = row.length > 0 ? !!row[0].blocked : false
}
if (isOrgPlan(sub.plan)) {
await unblockOrgMembers(sub.referenceId, 'payment_failed')
} else {
// Only unblock users blocked for payment_failed, not disputes
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(
and(
eq(userStats.userId, sub.referenceId),
eq(userStats.billingBlockedReason, 'payment_failed')
// For proration invoices (mid-cycle upgrades/seat changes), only unblock if real money
// was collected. A $0 credit invoice from a downgrade should not unblock a user who
// was blocked for a different failed payment.
const isProrationInvoice = invoice.billing_reason === 'subscription_update'
const shouldUnblock = !isProrationInvoice || (invoice.amount_paid ?? 0) > 0
if (shouldUnblock) {
if (isOrgPlan(sub.plan)) {
await unblockOrgMembers(sub.referenceId, 'payment_failed')
} else {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(
and(
eq(userStats.userId, sub.referenceId),
eq(userStats.billingBlockedReason, 'payment_failed')
)
)
)
}
} else {
logger.info('Skipping unblock for zero-amount proration invoice', {
invoiceId: invoice.id,
billingReason: invoice.billing_reason,
amountPaid: invoice.amount_paid,
})
}
if (wasBlocked) {
// Only reset usage for cycle renewals — proration invoices should not wipe
// accumulated usage mid-cycle.
if (wasBlocked && !isProrationInvoice) {
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
}
} catch (error) {
@@ -584,14 +599,6 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
// Block users after first payment failure
if (attemptCount >= 1) {
logger.error('Payment failure - blocking users', {
invoiceId: invoice.id,
customerId,
attemptCount,
isOverageInvoice,
stripeSubscriptionId,
})
const records = await db
.select()
.from(subscriptionTable)
@@ -600,6 +607,15 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
if (records.length > 0) {
const sub = records[0]
logger.error('Payment failure - blocking users', {
invoiceId: invoice.id,
customerId,
attemptCount,
isOverageInvoice,
stripeSubscriptionId,
})
if (isOrgPlan(sub.plan)) {
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
logger.info('Blocked team/enterprise members due to payment failure', {

View File

@@ -263,6 +263,8 @@ export const env = createEnv({
NOTION_CLIENT_SECRET: z.string().optional(), // Notion OAuth client secret
DISCORD_CLIENT_ID: z.string().optional(), // Discord OAuth client ID
DISCORD_CLIENT_SECRET: z.string().optional(), // Discord OAuth client secret
DOCUSIGN_CLIENT_ID: z.string().optional(), // DocuSign OAuth client ID
DOCUSIGN_CLIENT_SECRET: z.string().optional(), // DocuSign OAuth client secret
MICROSOFT_CLIENT_ID: z.string().optional(), // Microsoft OAuth client ID for Office 365/Teams
MICROSOFT_CLIENT_SECRET: z.string().optional(), // Microsoft OAuth client secret
HUBSPOT_CLIENT_ID: z.string().optional(), // HubSpot OAuth client ID
@@ -275,6 +277,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
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
DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID

View File

@@ -3,8 +3,10 @@ import {
AirtableIcon,
AsanaIcon,
AttioIcon,
BoxCompanyIcon,
CalComIcon,
ConfluenceIcon,
DocuSignIcon,
DropboxIcon,
GmailIcon,
GoogleAdsIcon,
@@ -571,6 +573,21 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
defaultService: 'linear',
},
box: {
name: 'Box',
icon: BoxCompanyIcon,
services: {
box: {
name: 'Box',
description: 'Manage files, folders, and e-signatures with Box.',
providerId: 'box',
icon: BoxCompanyIcon,
baseProviderIcon: BoxCompanyIcon,
scopes: ['root_readwrite', 'sign_requests.readwrite'],
},
},
defaultService: 'box',
},
dropbox: {
name: 'Dropbox',
icon: DropboxIcon,
@@ -779,6 +796,21 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
defaultService: 'calcom',
},
docusign: {
name: 'DocuSign',
icon: DocuSignIcon,
services: {
docusign: {
name: 'DocuSign',
description: 'Send documents for e-signature with DocuSign.',
providerId: 'docusign',
icon: DocuSignIcon,
baseProviderIcon: DocuSignIcon,
scopes: ['signature', 'extended'],
},
},
defaultService: 'docusign',
},
pipedrive: {
name: 'Pipedrive',
icon: PipedriveIcon,
@@ -1109,6 +1141,28 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
useBasicAuth: false,
}
}
case 'box': {
const { clientId, clientSecret } = getCredentials(env.BOX_CLIENT_ID, env.BOX_CLIENT_SECRET)
return {
tokenEndpoint: 'https://api.box.com/oauth2/token',
clientId,
clientSecret,
useBasicAuth: false,
}
}
case 'docusign': {
const { clientId, clientSecret } = getCredentials(
env.DOCUSIGN_CLIENT_ID,
env.DOCUSIGN_CLIENT_SECRET
)
return {
tokenEndpoint: 'https://account-d.docusign.com/oauth/token',
clientId,
clientSecret,
useBasicAuth: true,
supportsRefreshTokenRotation: false,
}
}
case 'dropbox': {
const { clientId, clientSecret } = getCredentials(
env.DROPBOX_CLIENT_ID,

View File

@@ -21,6 +21,7 @@ export type OAuthProvider =
| 'airtable'
| 'notion'
| 'jira'
| 'box'
| 'dropbox'
| 'microsoft'
| 'microsoft-dataverse'
@@ -47,6 +48,7 @@ export type OAuthProvider =
| 'wordpress'
| 'spotify'
| 'calcom'
| 'docusign'
export type OAuthService =
| 'google'
@@ -69,6 +71,7 @@ export type OAuthService =
| 'airtable'
| 'notion'
| 'jira'
| 'box'
| 'dropbox'
| 'microsoft-dataverse'
| 'microsoft-excel'
@@ -94,6 +97,7 @@ export type OAuthService =
| 'wordpress'
| 'spotify'
| 'calcom'
| 'docusign'
| 'github'
export interface OAuthProviderConfig {

View File

@@ -342,6 +342,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
// Box scopes
root_readwrite: 'Read and write all files and folders in Box account',
root_readonly: 'Read all files and folders in Box account',
'sign_requests.readwrite': 'Create and manage Box Sign e-signature requests',
// Shopify scopes
write_products: 'Read and manage Shopify products',
@@ -395,6 +396,10 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
'user-read-playback-position': 'View playback position in podcasts',
'ugc-image-upload': 'Upload images to Spotify playlists',
// DocuSign scopes
signature: 'Create and send envelopes for e-signature',
extended: 'Extended access to DocuSign account features',
// Attio scopes
'record_permission:read-write': 'Read and write CRM records',
'object_configuration:read-write': 'Read and manage object schemas',

View File

@@ -23,6 +23,10 @@ export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
knowledge: {
knowledgeBaseId: 'knowledgeBaseSelector',
},
ashby: {
emailType: '_removed_emailType',
phoneType: '_removed_phoneType',
},
}
/**

View File

@@ -796,6 +796,7 @@ describe('Schedule Deploy Utilities', () => {
expect(mockOnConflictDoUpdate).toHaveBeenCalledWith({
target: expect.any(Array),
targetWhere: expect.objectContaining({ type: 'isNull' }),
set: expect.objectContaining({
blockId: 'block-1',
cronExpression: '0 9 * * *',

View File

@@ -138,6 +138,7 @@ export async function createSchedulesForDeploy(
workflowSchedule.blockId,
workflowSchedule.deploymentVersionId,
],
targetWhere: isNull(workflowSchedule.archivedAt),
set: setValues,
})

View File

@@ -159,6 +159,7 @@
"resend": "^4.1.2",
"rss-parser": "3.13.0",
"sharp": "0.34.3",
"soap": "1.8.0",
"socket.io": "^4.8.1",
"socket.io-client": "4.8.1",
"ssh2": "^1.17.0",

View File

@@ -0,0 +1,76 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyAddCandidateTagParams {
apiKey: string
candidateId: string
tagId: string
}
interface AshbyAddCandidateTagResponse extends ToolResponse {
output: {
success: boolean
}
}
export const addCandidateTagTool: ToolConfig<
AshbyAddCandidateTagParams,
AshbyAddCandidateTagResponse
> = {
id: 'ashby_add_candidate_tag',
name: 'Ashby Add Candidate Tag',
description: 'Adds a tag to a candidate in Ashby.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
candidateId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The UUID of the candidate to add the tag to',
},
tagId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The UUID of the tag to add',
},
},
request: {
url: 'https://api.ashbyhq.com/candidate.addTag',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
candidateId: params.candidateId,
tagId: params.tagId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to add tag to candidate')
}
return {
success: true,
output: {
success: true,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Whether the tag was successfully added' },
},
}

View File

@@ -0,0 +1,94 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyChangeApplicationStageParams {
apiKey: string
applicationId: string
interviewStageId: string
archiveReasonId?: string
}
interface AshbyChangeApplicationStageResponse extends ToolResponse {
output: {
applicationId: string
stageId: string | null
}
}
export const changeApplicationStageTool: ToolConfig<
AshbyChangeApplicationStageParams,
AshbyChangeApplicationStageResponse
> = {
id: 'ashby_change_application_stage',
name: 'Ashby Change Application Stage',
description:
'Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
applicationId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The UUID of the application to update the stage of',
},
interviewStageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The UUID of the interview stage to move the application to',
},
archiveReasonId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Archive reason UUID. Required when moving to an Archived stage, ignored otherwise',
},
},
request: {
url: 'https://api.ashbyhq.com/application.changeStage',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => {
const body: Record<string, unknown> = {
applicationId: params.applicationId,
interviewStageId: params.interviewStageId,
}
if (params.archiveReasonId) body.archiveReasonId = params.archiveReasonId
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to change application stage')
}
const r = data.results
return {
success: true,
output: {
applicationId: r.id ?? null,
stageId: r.currentInterviewStage?.id ?? null,
},
}
},
outputs: {
applicationId: { type: 'string', description: 'Application UUID' },
stageId: { type: 'string', description: 'New interview stage UUID' },
},
}

View File

@@ -13,27 +13,7 @@ interface AshbyCreateApplicationParams {
interface AshbyCreateApplicationResponse extends ToolResponse {
output: {
id: string
status: string
candidate: {
id: string
name: string
}
job: {
id: string
title: string
}
currentInterviewStage: {
id: string
title: string
type: string
} | null
source: {
id: string
title: string
} | null
createdAt: string
updatedAt: string
applicationId: string
}
}
@@ -132,74 +112,12 @@ export const createApplicationTool: ToolConfig<
return {
success: true,
output: {
id: r.id ?? null,
status: r.status ?? null,
candidate: {
id: r.candidate?.id ?? null,
name: r.candidate?.name ?? null,
},
job: {
id: r.job?.id ?? null,
title: r.job?.title ?? null,
},
currentInterviewStage: r.currentInterviewStage
? {
id: r.currentInterviewStage.id ?? null,
title: r.currentInterviewStage.title ?? null,
type: r.currentInterviewStage.type ?? null,
}
: null,
source: r.source
? {
id: r.source.id ?? null,
title: r.source.title ?? null,
}
: null,
createdAt: r.createdAt ?? null,
updatedAt: r.updatedAt ?? null,
applicationId: r.applicationId ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Created application UUID' },
status: { type: 'string', description: 'Application status (Active, Hired, Archived, Lead)' },
candidate: {
type: 'object',
description: 'Associated candidate',
properties: {
id: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Candidate name' },
},
},
job: {
type: 'object',
description: 'Associated job',
properties: {
id: { type: 'string', description: 'Job UUID' },
title: { type: 'string', description: 'Job title' },
},
},
currentInterviewStage: {
type: 'object',
description: 'Current interview stage',
optional: true,
properties: {
id: { type: 'string', description: 'Stage UUID' },
title: { type: 'string', description: 'Stage title' },
type: { type: 'string', description: 'Stage type' },
},
},
source: {
type: 'object',
description: 'Application source',
optional: true,
properties: {
id: { type: 'string', description: 'Source UUID' },
title: { type: 'string', description: 'Source title' },
},
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
applicationId: { type: 'string', description: 'Created application UUID' },
},
}

View File

@@ -25,28 +25,16 @@ export const createCandidateTool: ToolConfig<
},
email: {
type: 'string',
required: false,
required: true,
visibility: 'user-or-llm',
description: 'Primary email address for the candidate',
},
emailType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Email address type: Personal, Work, or Other (default Work)',
},
phoneNumber: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Primary phone number for the candidate',
},
phoneType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Phone number type: Personal, Work, or Other (default Work)',
},
linkedInUrl: {
type: 'string',
required: false,
@@ -77,27 +65,11 @@ export const createCandidateTool: ToolConfig<
body: (params) => {
const body: Record<string, unknown> = {
name: params.name,
email: params.email,
}
if (params.email) {
body.primaryEmailAddress = {
value: params.email,
type: params.emailType || 'Work',
isPrimary: true,
}
}
if (params.phoneNumber) {
body.primaryPhoneNumber = {
value: params.phoneNumber,
type: params.phoneType || 'Work',
isPrimary: true,
}
}
if (params.linkedInUrl || params.githubUrl) {
const socialLinks: Array<{ url: string; type: string }> = []
if (params.linkedInUrl) socialLinks.push({ url: params.linkedInUrl, type: 'LinkedIn' })
if (params.githubUrl) socialLinks.push({ url: params.githubUrl, type: 'GitHub' })
body.socialLinks = socialLinks
}
if (params.phoneNumber) body.phoneNumber = params.phoneNumber
if (params.linkedInUrl) body.linkedInUrl = params.linkedInUrl
if (params.githubUrl) body.githubUrl = params.githubUrl
if (params.sourceId) body.sourceId = params.sourceId
return body
},

View File

@@ -78,35 +78,12 @@ export const createNoteTool: ToolConfig<AshbyCreateNoteParams, AshbyCreateNoteRe
return {
success: true,
output: {
id: r.id ?? null,
content: r.content ?? null,
author: r.author
? {
id: r.author.id ?? null,
firstName: r.author.firstName ?? null,
lastName: r.author.lastName ?? null,
email: r.author.email ?? null,
}
: null,
createdAt: r.createdAt ?? null,
noteId: r.id ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Created note UUID' },
content: { type: 'string', description: 'Note content as stored' },
author: {
type: 'object',
description: 'Note author',
optional: true,
properties: {
id: { type: 'string', description: 'Author user UUID' },
firstName: { type: 'string', description: 'First name' },
lastName: { type: 'string', description: 'Last name' },
email: { type: 'string', description: 'Email address' },
},
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
noteId: { type: 'string', description: 'Created note UUID' },
},
}

View File

@@ -30,7 +30,7 @@ export const getCandidateTool: ToolConfig<AshbyGetCandidateParams, AshbyGetCandi
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
candidateId: params.candidateId,
candidateId: params.candidateId.trim(),
}),
},

View File

@@ -30,7 +30,7 @@ export const getJobTool: ToolConfig<AshbyGetJobParams, AshbyGetJobResponse> = {
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
jobId: params.jobId,
jobId: params.jobId.trim(),
}),
},
@@ -63,7 +63,7 @@ export const getJobTool: ToolConfig<AshbyGetJobParams, AshbyGetJobResponse> = {
outputs: {
id: { type: 'string', description: 'Job UUID' },
title: { type: 'string', description: 'Job title' },
status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived, On Hold)' },
status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' },
employmentType: {
type: 'string',
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',

View File

@@ -0,0 +1,106 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyGetJobPostingParams {
apiKey: string
jobPostingId: string
}
interface AshbyGetJobPostingResponse extends ToolResponse {
output: {
id: string
title: string
jobId: string | null
locationName: string | null
departmentName: string | null
employmentType: string | null
descriptionPlain: string | null
isListed: boolean
publishedDate: string | null
externalLink: string | null
}
}
export const getJobPostingTool: ToolConfig<AshbyGetJobPostingParams, AshbyGetJobPostingResponse> = {
id: 'ashby_get_job_posting',
name: 'Ashby Get Job Posting',
description: 'Retrieves full details about a single job posting by its ID.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
jobPostingId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The UUID of the job posting to fetch',
},
},
request: {
url: 'https://api.ashbyhq.com/jobPosting.info',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
jobPostingId: params.jobPostingId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to get job posting')
}
const r = data.results
return {
success: true,
output: {
id: r.id ?? null,
title: r.jobTitle ?? r.title ?? null,
jobId: r.jobId ?? null,
locationName: r.locationName ?? null,
departmentName: r.departmentName ?? null,
employmentType: r.employmentType ?? null,
descriptionPlain: r.descriptionPlain ?? r.description ?? null,
isListed: r.isListed ?? false,
publishedDate: r.publishedDate ?? null,
externalLink: r.externalLink ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Job posting UUID' },
title: { type: 'string', description: 'Job posting title' },
jobId: { type: 'string', description: 'Associated job UUID', optional: true },
locationName: { type: 'string', description: 'Location name', optional: true },
departmentName: { type: 'string', description: 'Department name', optional: true },
employmentType: {
type: 'string',
description: 'Employment type (e.g. FullTime, PartTime, Contract)',
optional: true,
},
descriptionPlain: {
type: 'string',
description: 'Job posting description in plain text',
optional: true,
},
isListed: { type: 'boolean', description: 'Whether the posting is publicly listed' },
publishedDate: { type: 'string', description: 'ISO 8601 published date', optional: true },
externalLink: {
type: 'string',
description: 'External link to the job posting',
optional: true,
},
},
}

View File

@@ -0,0 +1,116 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyGetOfferParams {
apiKey: string
offerId: string
}
interface AshbyGetOfferResponse extends ToolResponse {
output: {
id: string
offerStatus: string
acceptanceStatus: string | null
applicationId: string | null
startDate: string | null
salary: {
currencyCode: string
value: number
} | null
openingId: string | null
createdAt: string | null
}
}
export const getOfferTool: ToolConfig<AshbyGetOfferParams, AshbyGetOfferResponse> = {
id: 'ashby_get_offer',
name: 'Ashby Get Offer',
description: 'Retrieves full details about a single offer by its ID.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
offerId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The UUID of the offer to fetch',
},
},
request: {
url: 'https://api.ashbyhq.com/offer.info',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
offerId: params.offerId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to get offer')
}
const r = data.results
const v = r.latestVersion
return {
success: true,
output: {
id: r.id ?? null,
offerStatus: r.offerStatus ?? null,
acceptanceStatus: r.acceptanceStatus ?? null,
applicationId: r.applicationId ?? null,
startDate: v?.startDate ?? null,
salary: v?.salary
? {
currencyCode: v.salary.currencyCode ?? null,
value: v.salary.value ?? null,
}
: null,
openingId: v?.openingId ?? null,
createdAt: v?.createdAt ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Offer UUID' },
offerStatus: {
type: 'string',
description: 'Offer status (e.g. WaitingOnCandidateResponse, CandidateAccepted)',
},
acceptanceStatus: {
type: 'string',
description: 'Acceptance status (e.g. Accepted, Declined, Pending)',
optional: true,
},
applicationId: { type: 'string', description: 'Associated application UUID', optional: true },
startDate: { type: 'string', description: 'Offer start date', optional: true },
salary: {
type: 'object',
description: 'Salary details',
optional: true,
properties: {
currencyCode: { type: 'string', description: 'ISO 4217 currency code' },
value: { type: 'number', description: 'Salary amount' },
},
},
openingId: { type: 'string', description: 'Associated opening UUID', optional: true },
createdAt: {
type: 'string',
description: 'ISO 8601 creation timestamp (from latest version)',
optional: true,
},
},
}

View File

@@ -1,28 +1,58 @@
import { addCandidateTagTool } from '@/tools/ashby/add_candidate_tag'
import { changeApplicationStageTool } from '@/tools/ashby/change_application_stage'
import { createApplicationTool } from '@/tools/ashby/create_application'
import { createCandidateTool } from '@/tools/ashby/create_candidate'
import { createNoteTool } from '@/tools/ashby/create_note'
import { getApplicationTool } from '@/tools/ashby/get_application'
import { getCandidateTool } from '@/tools/ashby/get_candidate'
import { getJobTool } from '@/tools/ashby/get_job'
import { getJobPostingTool } from '@/tools/ashby/get_job_posting'
import { getOfferTool } from '@/tools/ashby/get_offer'
import { listApplicationsTool } from '@/tools/ashby/list_applications'
import { listArchiveReasonsTool } from '@/tools/ashby/list_archive_reasons'
import { listCandidateTagsTool } from '@/tools/ashby/list_candidate_tags'
import { listCandidatesTool } from '@/tools/ashby/list_candidates'
import { listCustomFieldsTool } from '@/tools/ashby/list_custom_fields'
import { listDepartmentsTool } from '@/tools/ashby/list_departments'
import { listInterviewsTool } from '@/tools/ashby/list_interviews'
import { listJobPostingsTool } from '@/tools/ashby/list_job_postings'
import { listJobsTool } from '@/tools/ashby/list_jobs'
import { listLocationsTool } from '@/tools/ashby/list_locations'
import { listNotesTool } from '@/tools/ashby/list_notes'
import { listOffersTool } from '@/tools/ashby/list_offers'
import { listOpeningsTool } from '@/tools/ashby/list_openings'
import { listSourcesTool } from '@/tools/ashby/list_sources'
import { listUsersTool } from '@/tools/ashby/list_users'
import { removeCandidateTagTool } from '@/tools/ashby/remove_candidate_tag'
import { searchCandidatesTool } from '@/tools/ashby/search_candidates'
import { updateCandidateTool } from '@/tools/ashby/update_candidate'
export const ashbyAddCandidateTagTool = addCandidateTagTool
export const ashbyChangeApplicationStageTool = changeApplicationStageTool
export const ashbyCreateApplicationTool = createApplicationTool
export const ashbyCreateCandidateTool = createCandidateTool
export const ashbyCreateNoteTool = createNoteTool
export const ashbyGetApplicationTool = getApplicationTool
export const ashbyGetCandidateTool = getCandidateTool
export const ashbyGetJobTool = getJobTool
export const ashbyGetJobPostingTool = getJobPostingTool
export const ashbyGetOfferTool = getOfferTool
export const ashbyListApplicationsTool = listApplicationsTool
export const ashbyListArchiveReasonsTool = listArchiveReasonsTool
export const ashbyListCandidateTagsTool = listCandidateTagsTool
export const ashbyListCandidatesTool = listCandidatesTool
export const ashbyListCustomFieldsTool = listCustomFieldsTool
export const ashbyListDepartmentsTool = listDepartmentsTool
export const ashbyListInterviewsTool = listInterviewsTool
export const ashbyListJobPostingsTool = listJobPostingsTool
export const ashbyListJobsTool = listJobsTool
export const ashbyListLocationsTool = listLocationsTool
export const ashbyListNotesTool = listNotesTool
export const ashbyListOffersTool = listOffersTool
export const ashbyListOpeningsTool = listOpeningsTool
export const ashbyListSourcesTool = listSourcesTool
export const ashbyListUsersTool = listUsersTool
export const ashbyRemoveCandidateTagTool = removeCandidateTagTool
export const ashbySearchCandidatesTool = searchCandidatesTool
export const ashbyUpdateCandidateTool = updateCandidateTool

View File

@@ -71,7 +71,7 @@ export const listApplicationsTool: ToolConfig<
if (params.status) body.status = [params.status]
if (params.jobId) body.jobId = params.jobId
if (params.candidateId) body.candidateId = params.candidateId
if (params.createdAfter) body.createdAfter = params.createdAfter
if (params.createdAfter) body.createdAfter = new Date(params.createdAfter).getTime()
return body
},
},

View File

@@ -0,0 +1,81 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListArchiveReasonsParams {
apiKey: string
}
interface AshbyListArchiveReasonsResponse extends ToolResponse {
output: {
archiveReasons: Array<{
id: string
text: string
reasonType: string
isArchived: boolean
}>
}
}
export const listArchiveReasonsTool: ToolConfig<
AshbyListArchiveReasonsParams,
AshbyListArchiveReasonsResponse
> = {
id: 'ashby_list_archive_reasons',
name: 'Ashby List Archive Reasons',
description: 'Lists all archive reasons configured in Ashby.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
},
request: {
url: 'https://api.ashbyhq.com/archiveReason.list',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to list archive reasons')
}
return {
success: true,
output: {
archiveReasons: (data.results ?? []).map((r: Record<string, unknown>) => ({
id: r.id ?? null,
text: r.text ?? null,
reasonType: r.reasonType ?? null,
isArchived: r.isArchived ?? false,
})),
},
}
},
outputs: {
archiveReasons: {
type: 'array',
description: 'List of archive reasons',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Archive reason UUID' },
text: { type: 'string', description: 'Archive reason text' },
reasonType: { type: 'string', description: 'Reason type' },
isArchived: { type: 'boolean', description: 'Whether the reason is archived' },
},
},
},
},
}

View File

@@ -0,0 +1,78 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListCandidateTagsParams {
apiKey: string
}
interface AshbyListCandidateTagsResponse extends ToolResponse {
output: {
tags: Array<{
id: string
title: string
isArchived: boolean
}>
}
}
export const listCandidateTagsTool: ToolConfig<
AshbyListCandidateTagsParams,
AshbyListCandidateTagsResponse
> = {
id: 'ashby_list_candidate_tags',
name: 'Ashby List Candidate Tags',
description: 'Lists all candidate tags configured in Ashby.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
},
request: {
url: 'https://api.ashbyhq.com/candidateTag.list',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to list candidate tags')
}
return {
success: true,
output: {
tags: (data.results ?? []).map((t: Record<string, unknown>) => ({
id: t.id ?? null,
title: t.title ?? null,
isArchived: t.isArchived ?? false,
})),
},
}
},
outputs: {
tags: {
type: 'array',
description: 'List of candidate tags',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Tag UUID' },
title: { type: 'string', description: 'Tag title' },
isArchived: { type: 'boolean', description: 'Whether the tag is archived' },
},
},
},
},
}

View File

@@ -0,0 +1,87 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListCustomFieldsParams {
apiKey: string
}
interface AshbyListCustomFieldsResponse extends ToolResponse {
output: {
customFields: Array<{
id: string
title: string
fieldType: string
objectType: string
isArchived: boolean
}>
}
}
export const listCustomFieldsTool: ToolConfig<
AshbyListCustomFieldsParams,
AshbyListCustomFieldsResponse
> = {
id: 'ashby_list_custom_fields',
name: 'Ashby List Custom Fields',
description: 'Lists all custom field definitions configured in Ashby.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
},
request: {
url: 'https://api.ashbyhq.com/customField.list',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to list custom fields')
}
return {
success: true,
output: {
customFields: (data.results ?? []).map((f: Record<string, unknown>) => ({
id: f.id ?? null,
title: f.title ?? null,
fieldType: f.fieldType ?? null,
objectType: f.objectType ?? null,
isArchived: f.isArchived ?? false,
})),
},
}
},
outputs: {
customFields: {
type: 'array',
description: 'List of custom field definitions',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Custom field UUID' },
title: { type: 'string', description: 'Custom field title' },
fieldType: { type: 'string', description: 'Field type (e.g. String, Number, Boolean)' },
objectType: {
type: 'string',
description: 'Object type the field applies to (e.g. Candidate, Application, Job)',
},
isArchived: { type: 'boolean', description: 'Whether the custom field is archived' },
},
},
},
},
}

View File

@@ -0,0 +1,85 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListDepartmentsParams {
apiKey: string
}
interface AshbyListDepartmentsResponse extends ToolResponse {
output: {
departments: Array<{
id: string
name: string
isArchived: boolean
parentId: string | null
}>
}
}
export const listDepartmentsTool: ToolConfig<
AshbyListDepartmentsParams,
AshbyListDepartmentsResponse
> = {
id: 'ashby_list_departments',
name: 'Ashby List Departments',
description: 'Lists all departments in Ashby.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
},
request: {
url: 'https://api.ashbyhq.com/department.list',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to list departments')
}
return {
success: true,
output: {
departments: (data.results ?? []).map((d: Record<string, unknown>) => ({
id: d.id ?? null,
name: d.name ?? null,
isArchived: d.isArchived ?? false,
parentId: (d.parentId as string) ?? null,
})),
},
}
},
outputs: {
departments: {
type: 'array',
description: 'List of departments',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Department UUID' },
name: { type: 'string', description: 'Department name' },
isArchived: { type: 'boolean', description: 'Whether the department is archived' },
parentId: {
type: 'string',
description: 'Parent department UUID',
optional: true,
},
},
},
},
},
}

View File

@@ -0,0 +1,137 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListInterviewSchedulesParams {
apiKey: string
applicationId?: string
interviewStageId?: string
cursor?: string
perPage?: number
}
interface AshbyListInterviewSchedulesResponse extends ToolResponse {
output: {
interviewSchedules: Array<{
id: string
applicationId: string
interviewStageId: string | null
status: string | null
createdAt: string
}>
moreDataAvailable: boolean
nextCursor: string | null
}
}
export const listInterviewsTool: ToolConfig<
AshbyListInterviewSchedulesParams,
AshbyListInterviewSchedulesResponse
> = {
id: 'ashby_list_interviews',
name: 'Ashby List Interview Schedules',
description:
'Lists interview schedules in Ashby, optionally filtered by application or interview stage.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
applicationId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The UUID of the application to list interview schedules for',
},
interviewStageId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The UUID of the interview stage to list interview schedules for',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Opaque pagination cursor from a previous response nextCursor value',
},
perPage: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results per page (default 100)',
},
},
request: {
url: 'https://api.ashbyhq.com/interviewSchedule.list',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.applicationId) body.applicationId = params.applicationId
if (params.interviewStageId) body.interviewStageId = params.interviewStageId
if (params.cursor) body.cursor = params.cursor
if (params.perPage) body.limit = params.perPage
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to list interview schedules')
}
return {
success: true,
output: {
interviewSchedules: (data.results ?? []).map((s: Record<string, unknown>) => ({
id: s.id ?? null,
applicationId: s.applicationId ?? null,
interviewStageId: s.interviewStageId ?? null,
status: s.status ?? null,
createdAt: s.createdAt ?? null,
})),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
interviewSchedules: {
type: 'array',
description: 'List of interview schedules',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Interview schedule UUID' },
applicationId: { type: 'string', description: 'Associated application UUID' },
interviewStageId: {
type: 'string',
description: 'Interview stage UUID',
optional: true,
},
status: { type: 'string', description: 'Schedule status', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
},
},
},
moreDataAvailable: {
type: 'boolean',
description: 'Whether more pages of results exist',
},
nextCursor: {
type: 'string',
description: 'Opaque cursor for fetching the next page',
optional: true,
},
},
}

View File

@@ -0,0 +1,97 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListJobPostingsParams {
apiKey: string
}
interface AshbyListJobPostingsResponse extends ToolResponse {
output: {
jobPostings: Array<{
id: string
title: string
jobId: string | null
locationName: string | null
departmentName: string | null
employmentType: string | null
isListed: boolean
publishedDate: string | null
}>
}
}
export const listJobPostingsTool: ToolConfig<
AshbyListJobPostingsParams,
AshbyListJobPostingsResponse
> = {
id: 'ashby_list_job_postings',
name: 'Ashby List Job Postings',
description: 'Lists all job postings in Ashby.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
},
request: {
url: 'https://api.ashbyhq.com/jobPosting.list',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to list job postings')
}
return {
success: true,
output: {
jobPostings: (data.results ?? []).map((jp: Record<string, unknown>) => ({
id: jp.id ?? null,
title: (jp.jobTitle as string) ?? (jp.title as string) ?? null,
jobId: jp.jobId ?? null,
locationName: jp.locationName ?? null,
departmentName: jp.departmentName ?? null,
employmentType: jp.employmentType ?? null,
isListed: jp.isListed ?? false,
publishedDate: jp.publishedDate ?? null,
})),
},
}
},
outputs: {
jobPostings: {
type: 'array',
description: 'List of job postings',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Job posting UUID' },
title: { type: 'string', description: 'Job posting title' },
jobId: { type: 'string', description: 'Associated job UUID', optional: true },
locationName: { type: 'string', description: 'Location name', optional: true },
departmentName: { type: 'string', description: 'Department name', optional: true },
employmentType: {
type: 'string',
description: 'Employment type (e.g. FullTime, PartTime, Contract)',
optional: true,
},
isListed: { type: 'boolean', description: 'Whether the posting is publicly listed' },
publishedDate: { type: 'string', description: 'ISO 8601 published date', optional: true },
},
},
},
},
}

View File

@@ -0,0 +1,112 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListLocationsParams {
apiKey: string
}
interface AshbyListLocationsResponse extends ToolResponse {
output: {
locations: Array<{
id: string
name: string
isArchived: boolean
isRemote: boolean
address: {
city: string | null
region: string | null
country: string | null
} | null
}>
}
}
export const listLocationsTool: ToolConfig<AshbyListLocationsParams, AshbyListLocationsResponse> = {
id: 'ashby_list_locations',
name: 'Ashby List Locations',
description: 'Lists all locations configured in Ashby.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
},
request: {
url: 'https://api.ashbyhq.com/location.list',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to list locations')
}
return {
success: true,
output: {
locations: (data.results ?? []).map(
(
l: Record<string, unknown> & {
address?: {
postalAddress?: {
addressLocality?: string
addressRegion?: string
addressCountry?: string
}
}
}
) => ({
id: l.id ?? null,
name: l.name ?? null,
isArchived: l.isArchived ?? false,
isRemote: l.isRemote ?? false,
address: l.address?.postalAddress
? {
city: l.address.postalAddress.addressLocality ?? null,
region: l.address.postalAddress.addressRegion ?? null,
country: l.address.postalAddress.addressCountry ?? null,
}
: null,
})
),
},
}
},
outputs: {
locations: {
type: 'array',
description: 'List of locations',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Location UUID' },
name: { type: 'string', description: 'Location name' },
isArchived: { type: 'boolean', description: 'Whether the location is archived' },
isRemote: { type: 'boolean', description: 'Whether this is a remote location' },
address: {
type: 'object',
description: 'Location address',
optional: true,
properties: {
city: { type: 'string', description: 'City', optional: true },
region: { type: 'string', description: 'State or region', optional: true },
country: { type: 'string', description: 'Country', optional: true },
},
},
},
},
},
},
}

View File

@@ -10,17 +10,16 @@ interface AshbyListOffersResponse extends ToolResponse {
output: {
offers: Array<{
id: string
status: string
candidate: {
id: string
name: string
offerStatus: string
acceptanceStatus: string | null
applicationId: string | null
startDate: string | null
salary: {
currencyCode: string
value: number
} | null
job: {
id: string
title: string
} | null
createdAt: string
updatedAt: string
openingId: string | null
createdAt: string | null
}>
moreDataAvailable: boolean
nextCursor: string | null
@@ -82,27 +81,31 @@ export const listOffersTool: ToolConfig<AshbyListOffersParams, AshbyListOffersRe
offers: (data.results ?? []).map(
(
o: Record<string, unknown> & {
candidate?: { id?: string; name?: string }
job?: { id?: string; title?: string }
latestVersion?: {
startDate?: string
salary?: { currencyCode?: string; value?: number }
openingId?: string
createdAt?: string
}
}
) => ({
id: o.id ?? null,
status: o.status ?? o.offerStatus ?? null,
candidate: o.candidate
? {
id: o.candidate.id ?? null,
name: o.candidate.name ?? null,
}
: null,
job: o.job
? {
id: o.job.id ?? null,
title: o.job.title ?? null,
}
: null,
createdAt: o.createdAt ?? null,
updatedAt: o.updatedAt ?? null,
})
) => {
const v = o.latestVersion
return {
id: o.id ?? null,
offerStatus: o.offerStatus ?? null,
acceptanceStatus: o.acceptanceStatus ?? null,
applicationId: o.applicationId ?? null,
startDate: v?.startDate ?? null,
salary: v?.salary
? {
currencyCode: v.salary.currencyCode ?? null,
value: v.salary.value ?? null,
}
: null,
openingId: v?.openingId ?? null,
createdAt: v?.createdAt ?? null,
}
}
),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
@@ -118,27 +121,25 @@ export const listOffersTool: ToolConfig<AshbyListOffersParams, AshbyListOffersRe
type: 'object',
properties: {
id: { type: 'string', description: 'Offer UUID' },
status: { type: 'string', description: 'Offer status' },
candidate: {
offerStatus: { type: 'string', description: 'Offer status' },
acceptanceStatus: { type: 'string', description: 'Acceptance status', optional: true },
applicationId: {
type: 'string',
description: 'Associated application UUID',
optional: true,
},
startDate: { type: 'string', description: 'Offer start date', optional: true },
salary: {
type: 'object',
description: 'Associated candidate',
description: 'Salary details',
optional: true,
properties: {
id: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Candidate name' },
currencyCode: { type: 'string', description: 'ISO 4217 currency code' },
value: { type: 'number', description: 'Salary amount' },
},
},
job: {
type: 'object',
description: 'Associated job',
optional: true,
properties: {
id: { type: 'string', description: 'Job UUID' },
title: { type: 'string', description: 'Job title' },
},
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
openingId: { type: 'string', description: 'Associated opening UUID', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp', optional: true },
},
},
},

View File

@@ -0,0 +1,117 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListOpeningsParams {
apiKey: string
cursor?: string
perPage?: number
}
interface AshbyListOpeningsResponse extends ToolResponse {
output: {
openings: Array<{
id: string
openingState: string | null
isArchived: boolean
openedAt: string | null
closedAt: string | null
}>
moreDataAvailable: boolean
nextCursor: string | null
}
}
export const listOpeningsTool: ToolConfig<AshbyListOpeningsParams, AshbyListOpeningsResponse> = {
id: 'ashby_list_openings',
name: 'Ashby List Openings',
description: 'Lists all openings in Ashby with pagination.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Opaque pagination cursor from a previous response nextCursor value',
},
perPage: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results per page (default 100)',
},
},
request: {
url: 'https://api.ashbyhq.com/opening.list',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.cursor) body.cursor = params.cursor
if (params.perPage) body.limit = params.perPage
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to list openings')
}
return {
success: true,
output: {
openings: (data.results ?? []).map((o: Record<string, unknown>) => ({
id: o.id ?? null,
openingState: o.openingState ?? null,
isArchived: o.isArchived ?? false,
openedAt: o.openedAt ?? null,
closedAt: o.closedAt ?? null,
})),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
openings: {
type: 'array',
description: 'List of openings',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Opening UUID' },
openingState: {
type: 'string',
description: 'Opening state (Approved, Closed, Draft, Filled, Open)',
optional: true,
},
isArchived: { type: 'boolean', description: 'Whether the opening is archived' },
openedAt: { type: 'string', description: 'ISO 8601 opened timestamp', optional: true },
closedAt: { type: 'string', description: 'ISO 8601 closed timestamp', optional: true },
},
},
},
moreDataAvailable: {
type: 'boolean',
description: 'Whether more pages of results exist',
},
nextCursor: {
type: 'string',
description: 'Opaque cursor for fetching the next page',
optional: true,
},
},
}

View File

@@ -0,0 +1,75 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListSourcesParams {
apiKey: string
}
interface AshbyListSourcesResponse extends ToolResponse {
output: {
sources: Array<{
id: string
title: string
isArchived: boolean
}>
}
}
export const listSourcesTool: ToolConfig<AshbyListSourcesParams, AshbyListSourcesResponse> = {
id: 'ashby_list_sources',
name: 'Ashby List Sources',
description: 'Lists all candidate sources configured in Ashby.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
},
request: {
url: 'https://api.ashbyhq.com/source.list',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to list sources')
}
return {
success: true,
output: {
sources: (data.results ?? []).map((s: Record<string, unknown>) => ({
id: s.id ?? null,
title: s.title ?? null,
isArchived: s.isArchived ?? false,
})),
},
}
},
outputs: {
sources: {
type: 'array',
description: 'List of sources',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Source UUID' },
title: { type: 'string', description: 'Source title' },
isArchived: { type: 'boolean', description: 'Whether the source is archived' },
},
},
},
},
}

View File

@@ -0,0 +1,121 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListUsersParams {
apiKey: string
cursor?: string
perPage?: number
}
interface AshbyListUsersResponse extends ToolResponse {
output: {
users: Array<{
id: string
firstName: string
lastName: string
email: string
isEnabled: boolean
globalRole: string | null
}>
moreDataAvailable: boolean
nextCursor: string | null
}
}
export const listUsersTool: ToolConfig<AshbyListUsersParams, AshbyListUsersResponse> = {
id: 'ashby_list_users',
name: 'Ashby List Users',
description: 'Lists all users in Ashby with pagination.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Opaque pagination cursor from a previous response nextCursor value',
},
perPage: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results per page (default 100)',
},
},
request: {
url: 'https://api.ashbyhq.com/user.list',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.cursor) body.cursor = params.cursor
if (params.perPage) body.limit = params.perPage
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to list users')
}
return {
success: true,
output: {
users: (data.results ?? []).map((u: Record<string, unknown>) => ({
id: u.id ?? null,
firstName: u.firstName ?? null,
lastName: u.lastName ?? null,
email: u.email ?? null,
isEnabled: u.isEnabled ?? false,
globalRole: u.globalRole ?? null,
})),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
},
}
},
outputs: {
users: {
type: 'array',
description: 'List of users',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'User UUID' },
firstName: { type: 'string', description: 'First name' },
lastName: { type: 'string', description: 'Last name' },
email: { type: 'string', description: 'Email address' },
isEnabled: { type: 'boolean', description: 'Whether the user account is enabled' },
globalRole: {
type: 'string',
description:
'User role (Organization Admin, Elevated Access, Limited Access, External Recruiter)',
optional: true,
},
},
},
},
moreDataAvailable: {
type: 'boolean',
description: 'Whether more pages of results exist',
},
nextCursor: {
type: 'string',
description: 'Opaque cursor for fetching the next page',
optional: true,
},
},
}

View File

@@ -0,0 +1,76 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyRemoveCandidateTagParams {
apiKey: string
candidateId: string
tagId: string
}
interface AshbyRemoveCandidateTagResponse extends ToolResponse {
output: {
success: boolean
}
}
export const removeCandidateTagTool: ToolConfig<
AshbyRemoveCandidateTagParams,
AshbyRemoveCandidateTagResponse
> = {
id: 'ashby_remove_candidate_tag',
name: 'Ashby Remove Candidate Tag',
description: 'Removes a tag from a candidate in Ashby.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
candidateId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The UUID of the candidate to remove the tag from',
},
tagId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The UUID of the tag to remove',
},
},
request: {
url: 'https://api.ashbyhq.com/candidate.removeTag',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
candidateId: params.candidateId,
tagId: params.tagId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.errorInfo?.message || 'Failed to remove tag from candidate')
}
return {
success: true,
output: {
success: true,
},
}
},
outputs: {
success: { type: 'boolean', description: 'Whether the tag was successfully removed' },
},
}

View File

@@ -80,6 +80,8 @@ export const searchCandidatesTool: ToolConfig<
isPrimary: c.primaryPhoneNumber.isPrimary ?? true,
}
: null,
createdAt: c.createdAt ?? null,
updatedAt: c.updatedAt ?? null,
})
),
},
@@ -115,6 +117,8 @@ export const searchCandidatesTool: ToolConfig<
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
},
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
},
},
},

View File

@@ -21,10 +21,8 @@ export interface AshbyGetCandidateParams extends AshbyBaseParams {
export interface AshbyCreateCandidateParams extends AshbyBaseParams {
name: string
email?: string
emailType?: string
email: string
phoneNumber?: string
phoneType?: string
linkedInUrl?: string
githubUrl?: string
sourceId?: string
@@ -111,6 +109,8 @@ export interface AshbySearchCandidatesResponse extends ToolResponse {
name: string
primaryEmailAddress: AshbyContactInfo | null
primaryPhoneNumber: AshbyContactInfo | null
createdAt: string
updatedAt: string
}>
}
}
@@ -149,15 +149,7 @@ export interface AshbyGetJobResponse extends ToolResponse {
export interface AshbyCreateNoteResponse extends ToolResponse {
output: {
id: string
content: string
author: {
id: string
firstName: string
lastName: string
email: string
} | null
createdAt: string
noteId: string
}
}

View File

@@ -6,9 +6,7 @@ interface AshbyUpdateCandidateParams {
candidateId: string
name?: string
email?: string
emailType?: string
phoneNumber?: string
phoneType?: string
linkedInUrl?: string
githubUrl?: string
websiteUrl?: string
@@ -49,24 +47,12 @@ export const updateCandidateTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Updated primary email address',
},
emailType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Email address type: Personal, Work, or Other (default Work)',
},
phoneNumber: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Updated primary phone number',
},
phoneType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Phone number type: Personal, Work, or Other (default Work)',
},
linkedInUrl: {
type: 'string',
required: false,
@@ -105,27 +91,11 @@ export const updateCandidateTool: ToolConfig<
candidateId: params.candidateId,
}
if (params.name) body.name = params.name
if (params.email) {
body.primaryEmailAddress = {
value: params.email,
type: params.emailType || 'Work',
isPrimary: true,
}
}
if (params.phoneNumber) {
body.primaryPhoneNumber = {
value: params.phoneNumber,
type: params.phoneType || 'Work',
isPrimary: true,
}
}
if (params.linkedInUrl || params.githubUrl || params.websiteUrl) {
const socialLinks: Array<{ url: string; type: string }> = []
if (params.linkedInUrl) socialLinks.push({ url: params.linkedInUrl, type: 'LinkedIn' })
if (params.githubUrl) socialLinks.push({ url: params.githubUrl, type: 'GitHub' })
if (params.websiteUrl) socialLinks.push({ url: params.websiteUrl, type: 'Website' })
body.socialLinks = socialLinks
}
if (params.email) body.email = params.email
if (params.phoneNumber) body.phoneNumber = params.phoneNumber
if (params.linkedInUrl) body.linkedInUrl = params.linkedInUrl
if (params.githubUrl) body.githubUrl = params.githubUrl
if (params.websiteUrl) body.websiteUrl = params.websiteUrl
if (params.sourceId) body.sourceId = params.sourceId
return body
},

View File

@@ -0,0 +1,82 @@
import type { ToolConfig } from '@/tools/types'
import type { BoxCopyFileParams, BoxUploadFileResponse } from './types'
import { UPLOAD_FILE_OUTPUT_PROPERTIES } from './types'
export const boxCopyFileTool: ToolConfig<BoxCopyFileParams, BoxUploadFileResponse> = {
id: 'box_copy_file',
name: 'Box Copy File',
description: 'Copy a file to another folder in Box',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
fileId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the file to copy',
},
parentFolderId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the destination folder',
},
name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional new name for the copied file',
},
},
request: {
url: (params) => `https://api.box.com/2.0/files/${params.fileId.trim()}/copy`,
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, unknown> = {
parent: { id: params.parentFolderId.trim() },
}
if (params.name) body.name = params.name
return body
},
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || `Box API error: ${response.status}`)
}
return {
success: true,
output: {
id: data.id ?? '',
name: data.name ?? '',
size: data.size ?? 0,
sha1: data.sha1 ?? null,
createdAt: data.created_at ?? null,
modifiedAt: data.modified_at ?? null,
parentId: data.parent?.id ?? null,
parentName: data.parent?.name ?? null,
},
}
},
outputs: UPLOAD_FILE_OUTPUT_PROPERTIES,
}

View File

@@ -0,0 +1,71 @@
import type { ToolConfig } from '@/tools/types'
import type { BoxCreateFolderParams, BoxFolderResponse } from './types'
import { FOLDER_OUTPUT_PROPERTIES } from './types'
export const boxCreateFolderTool: ToolConfig<BoxCreateFolderParams, BoxFolderResponse> = {
id: 'box_create_folder',
name: 'Box Create Folder',
description: 'Create a new folder in Box',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Name for the new folder',
},
parentFolderId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the parent folder (use "0" for root)',
},
},
request: {
url: 'https://api.box.com/2.0/folders',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => ({
name: params.name,
parent: { id: params.parentFolderId.trim() },
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || `Box API error: ${response.status}`)
}
return {
success: true,
output: {
id: data.id ?? '',
name: data.name ?? '',
createdAt: data.created_at ?? null,
modifiedAt: data.modified_at ?? null,
parentId: data.parent?.id ?? null,
parentName: data.parent?.name ?? null,
},
}
},
outputs: FOLDER_OUTPUT_PROPERTIES,
}

View File

@@ -0,0 +1,57 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
import type { BoxDeleteFileParams } from './types'
export const boxDeleteFileTool: ToolConfig<BoxDeleteFileParams, ToolResponse> = {
id: 'box_delete_file',
name: 'Box Delete File',
description: 'Delete a file from Box',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
fileId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the file to delete',
},
},
request: {
url: (params) => `https://api.box.com/2.0/files/${params.fileId.trim()}`,
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response) => {
if (response.status === 204) {
return {
success: true,
output: {
deleted: true,
message: 'File deleted successfully',
},
}
}
const data = await response.json()
throw new Error(data.message || `Box API error: ${response.status}`)
},
outputs: {
deleted: { type: 'boolean', description: 'Whether the file was successfully deleted' },
message: { type: 'string', description: 'Success confirmation message' },
},
}

View File

@@ -0,0 +1,68 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
import type { BoxDeleteFolderParams } from './types'
export const boxDeleteFolderTool: ToolConfig<BoxDeleteFolderParams, ToolResponse> = {
id: 'box_delete_folder',
name: 'Box Delete Folder',
description: 'Delete a folder from Box',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
folderId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the folder to delete',
},
recursive: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Delete folder and all its contents recursively',
},
},
request: {
url: (params) => {
const queryParams = new URLSearchParams()
if (params.recursive) queryParams.set('recursive', 'true')
const qs = queryParams.toString()
return `https://api.box.com/2.0/folders/${params.folderId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response) => {
if (response.status === 204) {
return {
success: true,
output: {
deleted: true,
message: 'Folder deleted successfully',
},
}
}
const data = await response.json()
throw new Error(data.message || `Box API error: ${response.status}`)
},
outputs: {
deleted: { type: 'boolean', description: 'Whether the folder was successfully deleted' },
message: { type: 'string', description: 'Success confirmation message' },
},
}

View File

@@ -0,0 +1,87 @@
import type { ToolConfig } from '@/tools/types'
import type { BoxDownloadFileParams, BoxDownloadFileResponse } from './types'
export const boxDownloadFileTool: ToolConfig<BoxDownloadFileParams, BoxDownloadFileResponse> = {
id: 'box_download_file',
name: 'Box Download File',
description: 'Download a file from Box',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
fileId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the file to download',
},
},
request: {
url: (params) => `https://api.box.com/2.0/files/${params.fileId.trim()}/content`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response) => {
if (response.status === 202) {
const retryAfter = response.headers.get('retry-after') || 'a few'
throw new Error(`File is not yet ready for download. Retry after ${retryAfter} seconds.`)
}
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText || `Failed to download file: ${response.status}`)
}
const contentType = response.headers.get('content-type') || 'application/octet-stream'
const contentDisposition = response.headers.get('content-disposition')
let fileName = 'download'
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match?.[1]) {
fileName = match[1].replace(/['"]/g, '')
}
}
const arrayBuffer = await response.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
return {
success: true,
output: {
file: {
name: fileName,
mimeType: contentType,
data: buffer.toString('base64'),
size: buffer.length,
},
content: buffer.toString('base64'),
},
}
},
outputs: {
file: {
type: 'file',
description: 'Downloaded file stored in execution files',
},
content: {
type: 'string',
description: 'Base64 encoded file content',
},
},
}

View File

@@ -0,0 +1,87 @@
import type { ToolConfig } from '@/tools/types'
import type { BoxFileInfoResponse, BoxGetFileInfoParams } from './types'
import { FILE_OUTPUT_PROPERTIES } from './types'
export const boxGetFileInfoTool: ToolConfig<BoxGetFileInfoParams, BoxFileInfoResponse> = {
id: 'box_get_file_info',
name: 'Box Get File Info',
description: 'Get detailed information about a file in Box',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
fileId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the file to get information about',
},
},
request: {
url: (params) => `https://api.box.com/2.0/files/${params.fileId.trim()}`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || `Box API error: ${response.status}`)
}
return {
success: true,
output: {
id: data.id ?? '',
name: data.name ?? '',
description: data.description ?? null,
size: data.size ?? 0,
sha1: data.sha1 ?? null,
createdAt: data.created_at ?? null,
modifiedAt: data.modified_at ?? null,
createdBy: data.created_by
? {
id: data.created_by.id,
name: data.created_by.name,
login: data.created_by.login,
}
: null,
modifiedBy: data.modified_by
? {
id: data.modified_by.id,
name: data.modified_by.name,
login: data.modified_by.login,
}
: null,
ownedBy: data.owned_by
? {
id: data.owned_by.id,
name: data.owned_by.name,
login: data.owned_by.login,
}
: null,
parentId: data.parent?.id ?? null,
parentName: data.parent?.name ?? null,
sharedLink: data.shared_link ?? null,
tags: data.tags ?? [],
commentCount: data.comment_count ?? null,
},
}
},
outputs: FILE_OUTPUT_PROPERTIES,
}

View File

@@ -0,0 +1,10 @@
export { boxCopyFileTool } from '@/tools/box/copy_file'
export { boxCreateFolderTool } from '@/tools/box/create_folder'
export { boxDeleteFileTool } from '@/tools/box/delete_file'
export { boxDeleteFolderTool } from '@/tools/box/delete_folder'
export { boxDownloadFileTool } from '@/tools/box/download_file'
export { boxGetFileInfoTool } from '@/tools/box/get_file_info'
export { boxListFolderItemsTool } from '@/tools/box/list_folder_items'
export { boxSearchTool } from '@/tools/box/search'
export { boxUpdateFileTool } from '@/tools/box/update_file'
export { boxUploadFileTool } from '@/tools/box/upload_file'

View File

@@ -0,0 +1,98 @@
import type { ToolConfig } from '@/tools/types'
import type { BoxFolderItemsResponse, BoxListFolderItemsParams } from './types'
import { FOLDER_ITEMS_OUTPUT_PROPERTIES } from './types'
export const boxListFolderItemsTool: ToolConfig<BoxListFolderItemsParams, BoxFolderItemsResponse> =
{
id: 'box_list_folder_items',
name: 'Box List Folder Items',
description: 'List files and folders in a Box folder',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
folderId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the folder to list items from (use "0" for root)',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of items to return per page',
},
offset: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'The offset for pagination',
},
sort: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Sort field: id, name, date, or size',
},
direction: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Sort direction: ASC or DESC',
},
},
request: {
url: (params) => {
const queryParams = new URLSearchParams()
if (params.limit !== undefined) queryParams.set('limit', String(params.limit))
if (params.offset !== undefined) queryParams.set('offset', String(params.offset))
if (params.sort) queryParams.set('sort', params.sort)
if (params.direction) queryParams.set('direction', params.direction)
const qs = queryParams.toString()
return `https://api.box.com/2.0/folders/${params.folderId.trim()}/items${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || `Box API error: ${response.status}`)
}
return {
success: true,
output: {
entries: (data.entries ?? []).map((item: Record<string, unknown>) => ({
type: item.type ?? '',
id: item.id ?? '',
name: item.name ?? '',
size: item.size ?? null,
createdAt: item.created_at ?? null,
modifiedAt: item.modified_at ?? null,
})),
totalCount: data.total_count ?? 0,
offset: data.offset ?? 0,
limit: data.limit ?? 0,
},
}
},
outputs: FOLDER_ITEMS_OUTPUT_PROPERTIES,
}

View File

@@ -0,0 +1,105 @@
import type { ToolConfig } from '@/tools/types'
import type { BoxSearchParams, BoxSearchResponse } from './types'
import { SEARCH_RESULT_OUTPUT_PROPERTIES } from './types'
export const boxSearchTool: ToolConfig<BoxSearchParams, BoxSearchResponse> = {
id: 'box_search',
name: 'Box Search',
description: 'Search for files and folders in Box',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
query: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The search query string',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of results to return',
},
offset: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'The offset for pagination',
},
ancestorFolderId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Restrict search to a specific folder and its subfolders',
},
fileExtensions: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated file extensions to filter by (e.g., pdf,docx)',
},
type: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Restrict to a specific content type: file, folder, or web_link',
},
},
request: {
url: (params) => {
const queryParams = new URLSearchParams()
queryParams.set('query', params.query)
if (params.limit !== undefined) queryParams.set('limit', String(params.limit))
if (params.offset !== undefined) queryParams.set('offset', String(params.offset))
if (params.ancestorFolderId)
queryParams.set('ancestor_folder_ids', params.ancestorFolderId.trim())
if (params.fileExtensions) queryParams.set('file_extensions', params.fileExtensions)
if (params.type) queryParams.set('type', params.type)
return `https://api.box.com/2.0/search?${queryParams.toString()}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || `Box API error: ${response.status}`)
}
return {
success: true,
output: {
results: (data.entries ?? []).map((item: Record<string, unknown>) => ({
type: item.type ?? '',
id: item.id ?? '',
name: item.name ?? '',
size: item.size ?? null,
createdAt: item.created_at ?? null,
modifiedAt: item.modified_at ?? null,
parentId: (item.parent as Record<string, unknown> | undefined)?.id ?? null,
parentName: (item.parent as Record<string, unknown> | undefined)?.name ?? null,
})),
totalCount: data.total_count ?? 0,
},
}
},
outputs: SEARCH_RESULT_OUTPUT_PROPERTIES,
}

249
apps/sim/tools/box/types.ts Normal file
View File

@@ -0,0 +1,249 @@
import type { OutputProperty, ToolResponse } from '@/tools/types'
export interface BoxUploadFileParams {
accessToken: string
parentFolderId: string
file?: unknown
fileContent?: string
fileName?: string
}
export interface BoxDownloadFileParams {
accessToken: string
fileId: string
}
export interface BoxGetFileInfoParams {
accessToken: string
fileId: string
}
export interface BoxListFolderItemsParams {
accessToken: string
folderId: string
limit?: number
offset?: number
sort?: string
direction?: string
}
export interface BoxCreateFolderParams {
accessToken: string
name: string
parentFolderId: string
}
export interface BoxDeleteFileParams {
accessToken: string
fileId: string
}
export interface BoxDeleteFolderParams {
accessToken: string
folderId: string
recursive?: boolean
}
export interface BoxCopyFileParams {
accessToken: string
fileId: string
parentFolderId: string
name?: string
}
export interface BoxSearchParams {
accessToken: string
query: string
limit?: number
offset?: number
ancestorFolderId?: string
fileExtensions?: string
type?: string
}
export interface BoxUpdateFileParams {
accessToken: string
fileId: string
name?: string
description?: string
parentFolderId?: string
tags?: string
}
export interface BoxUploadFileResponse extends ToolResponse {
output: {
id: string
name: string
size: number
sha1: string | null
createdAt: string | null
modifiedAt: string | null
parentId: string | null
parentName: string | null
}
}
export interface BoxDownloadFileResponse extends ToolResponse {
output: {
file: {
name: string
mimeType: string
data: string
size: number
}
content: string
}
}
export interface BoxFileInfoResponse extends ToolResponse {
output: {
id: string
name: string
description: string | null
size: number
sha1: string | null
createdAt: string | null
modifiedAt: string | null
createdBy: { id: string; name: string; login: string } | null
modifiedBy: { id: string; name: string; login: string } | null
ownedBy: { id: string; name: string; login: string } | null
parentId: string | null
parentName: string | null
sharedLink: Record<string, unknown> | null
tags: string[]
commentCount: number | null
}
}
export interface BoxFolderItemsResponse extends ToolResponse {
output: {
entries: Array<Record<string, unknown>>
totalCount: number
offset: number
limit: number
}
}
export interface BoxFolderResponse extends ToolResponse {
output: {
id: string
name: string
createdAt: string | null
modifiedAt: string | null
parentId: string | null
parentName: string | null
}
}
export interface BoxSearchResponse extends ToolResponse {
output: {
results: Array<Record<string, unknown>>
totalCount: number
}
}
const USER_OUTPUT_PROPERTIES = {
id: { type: 'string', description: 'User ID' },
name: { type: 'string', description: 'User name' },
login: { type: 'string', description: 'User email/login' },
} as const satisfies Record<string, OutputProperty>
export const FILE_OUTPUT_PROPERTIES = {
id: { type: 'string', description: 'File ID' },
name: { type: 'string', description: 'File name' },
description: { type: 'string', description: 'File description', optional: true },
size: { type: 'number', description: 'File size in bytes' },
sha1: { type: 'string', description: 'SHA1 hash of file content', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
modifiedAt: { type: 'string', description: 'Last modified timestamp', optional: true },
createdBy: {
type: 'object',
description: 'User who created the file',
optional: true,
properties: USER_OUTPUT_PROPERTIES,
},
modifiedBy: {
type: 'object',
description: 'User who last modified the file',
optional: true,
properties: USER_OUTPUT_PROPERTIES,
},
ownedBy: {
type: 'object',
description: 'User who owns the file',
optional: true,
properties: USER_OUTPUT_PROPERTIES,
},
parentId: { type: 'string', description: 'Parent folder ID', optional: true },
parentName: { type: 'string', description: 'Parent folder name', optional: true },
sharedLink: { type: 'json', description: 'Shared link details', optional: true },
tags: {
type: 'array',
description: 'File tags',
items: { type: 'string' },
optional: true,
},
commentCount: { type: 'number', description: 'Number of comments', optional: true },
} as const satisfies Record<string, OutputProperty>
export const FOLDER_ITEMS_OUTPUT_PROPERTIES = {
entries: {
type: 'array',
description: 'List of items in the folder',
items: {
type: 'object',
properties: {
type: { type: 'string', description: 'Item type (file, folder, web_link)' },
id: { type: 'string', description: 'Item ID' },
name: { type: 'string', description: 'Item name' },
size: { type: 'number', description: 'Item size in bytes', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
modifiedAt: { type: 'string', description: 'Last modified timestamp', optional: true },
},
},
},
totalCount: { type: 'number', description: 'Total number of items in the folder' },
offset: { type: 'number', description: 'Current pagination offset' },
limit: { type: 'number', description: 'Current pagination limit' },
} as const satisfies Record<string, OutputProperty>
export const FOLDER_OUTPUT_PROPERTIES = {
id: { type: 'string', description: 'Folder ID' },
name: { type: 'string', description: 'Folder name' },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
modifiedAt: { type: 'string', description: 'Last modified timestamp', optional: true },
parentId: { type: 'string', description: 'Parent folder ID', optional: true },
parentName: { type: 'string', description: 'Parent folder name', optional: true },
} as const satisfies Record<string, OutputProperty>
export const SEARCH_RESULT_OUTPUT_PROPERTIES = {
results: {
type: 'array',
description: 'Search results',
items: {
type: 'object',
properties: {
type: { type: 'string', description: 'Item type (file, folder, web_link)' },
id: { type: 'string', description: 'Item ID' },
name: { type: 'string', description: 'Item name' },
size: { type: 'number', description: 'Item size in bytes', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
modifiedAt: { type: 'string', description: 'Last modified timestamp', optional: true },
parentId: { type: 'string', description: 'Parent folder ID', optional: true },
parentName: { type: 'string', description: 'Parent folder name', optional: true },
},
},
},
totalCount: { type: 'number', description: 'Total number of matching results' },
} as const satisfies Record<string, OutputProperty>
export const UPLOAD_FILE_OUTPUT_PROPERTIES = {
id: { type: 'string', description: 'File ID' },
name: { type: 'string', description: 'File name' },
size: { type: 'number', description: 'File size in bytes' },
sha1: { type: 'string', description: 'SHA1 hash of file content', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
modifiedAt: { type: 'string', description: 'Last modified timestamp', optional: true },
parentId: { type: 'string', description: 'Parent folder ID', optional: true },
parentName: { type: 'string', description: 'Parent folder name', optional: true },
} as const satisfies Record<string, OutputProperty>

View File

@@ -0,0 +1,124 @@
import type { ToolConfig } from '@/tools/types'
import type { BoxFileInfoResponse, BoxUpdateFileParams } from './types'
import { FILE_OUTPUT_PROPERTIES } from './types'
export const boxUpdateFileTool: ToolConfig<BoxUpdateFileParams, BoxFileInfoResponse> = {
id: 'box_update_file',
name: 'Box Update File',
description: 'Update file info in Box (rename, move, change description, add tags)',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
fileId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the file to update',
},
name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New name for the file',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New description for the file (max 256 characters)',
},
parentFolderId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Move the file to a different folder by specifying the folder ID',
},
tags: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated tags to set on the file',
},
},
request: {
url: (params) => `https://api.box.com/2.0/files/${params.fileId.trim()}`,
method: 'PUT',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.name) body.name = params.name
if (params.description !== undefined) body.description = params.description
if (params.parentFolderId) body.parent = { id: params.parentFolderId.trim() }
if (params.tags)
body.tags = params.tags
.split(',')
.map((t: string) => t.trim())
.filter(Boolean)
return body
},
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || `Box API error: ${response.status}`)
}
return {
success: true,
output: {
id: data.id ?? '',
name: data.name ?? '',
description: data.description ?? null,
size: data.size ?? 0,
sha1: data.sha1 ?? null,
createdAt: data.created_at ?? null,
modifiedAt: data.modified_at ?? null,
createdBy: data.created_by
? {
id: data.created_by.id,
name: data.created_by.name,
login: data.created_by.login,
}
: null,
modifiedBy: data.modified_by
? {
id: data.modified_by.id,
name: data.modified_by.name,
login: data.modified_by.login,
}
: null,
ownedBy: data.owned_by
? {
id: data.owned_by.id,
name: data.owned_by.name,
login: data.owned_by.login,
}
: null,
parentId: data.parent?.id ?? null,
parentName: data.parent?.name ?? null,
sharedLink: data.shared_link ?? null,
tags: data.tags ?? [],
commentCount: data.comment_count ?? null,
},
}
},
outputs: FILE_OUTPUT_PROPERTIES,
}

View File

@@ -0,0 +1,78 @@
import type { ToolConfig } from '@/tools/types'
import type { BoxUploadFileParams, BoxUploadFileResponse } from './types'
import { UPLOAD_FILE_OUTPUT_PROPERTIES } from './types'
export const boxUploadFileTool: ToolConfig<BoxUploadFileParams, BoxUploadFileResponse> = {
id: 'box_upload_file',
name: 'Box Upload File',
description: 'Upload a file to a Box folder',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
parentFolderId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the folder to upload the file to (use "0" for root)',
},
file: {
type: 'file',
required: false,
visibility: 'user-or-llm',
description: 'The file to upload (UserFile object)',
},
fileContent: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'Legacy: base64 encoded file content',
},
fileName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional filename override',
},
},
request: {
url: '/api/tools/box/upload',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
accessToken: params.accessToken,
parentFolderId: params.parentFolderId,
file: params.file,
fileContent: params.fileContent,
fileName: params.fileName,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to upload file')
}
return {
success: true,
output: data.output,
}
},
outputs: UPLOAD_FILE_OUTPUT_PROPERTIES,
}

View File

@@ -0,0 +1,80 @@
import type { ToolConfig } from '@/tools/types'
import type { BoxSignCancelRequestParams, BoxSignResponse } from './types'
import { SIGN_REQUEST_OUTPUT_PROPERTIES } from './types'
export const boxSignCancelRequestTool: ToolConfig<BoxSignCancelRequestParams, BoxSignResponse> = {
id: 'box_sign_cancel_request',
name: 'Box Sign Cancel Request',
description: 'Cancel a pending Box Sign request',
version: '1.0.0',
oauth: {
required: true,
provider: 'box',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Box API',
},
signRequestId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the sign request to cancel',
},
},
request: {
url: (params) => `https://api.box.com/2.0/sign_requests/${params.signRequestId}/cancel`,
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: () => ({}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || `Box Sign API error: ${response.status}`)
}
return {
success: true,
output: {
id: data.id ?? '',
status: data.status ?? '',
name: data.name ?? null,
shortId: data.short_id ?? null,
signers: (data.signers ?? []).map((s: Record<string, unknown>) => ({
email: s.email ?? null,
role: s.role ?? null,
hasViewedDocument: s.has_viewed_document ?? null,
signerDecision: s.signer_decision ?? null,
embedUrl: s.embed_url ?? null,
order: s.order ?? null,
})),
sourceFiles: (data.source_files ?? []).map((f: Record<string, unknown>) => ({
id: f.id ?? null,
type: f.type ?? null,
name: f.name ?? null,
})),
emailSubject: data.email_subject ?? null,
emailMessage: data.email_message ?? null,
daysValid: data.days_valid ?? null,
createdAt: data.created_at ?? null,
autoExpireAt: data.auto_expire_at ?? null,
prepareUrl: data.prepare_url ?? null,
senderEmail: data.sender_email ?? null,
},
}
},
outputs: SIGN_REQUEST_OUTPUT_PROPERTIES,
}

Some files were not shown because too many files have changed in this diff Show More