improvement(ux): added tab key navigation for agent messages, made variables styling match chat, added neo4j and calendly (#2056)

* improvement(ux): added tab key navigation for agent messages, made variables styling match chat

* added neo4j tools, added calendly tools and triggers

* more style improvements

* consolidate wand generation type

* more ui improvements

* fix calendly

* tested all neo4j tools

* added fuzzy search for search modal, tested and fixed calendly

* updated docs

* fix various broken docs links, neo4j param validation

* removed limit from neo4j block
This commit is contained in:
Waleed
2025-11-19 17:40:20 -08:00
committed by GitHub
parent c93f6620f6
commit becd19bc50
74 changed files with 6025 additions and 314 deletions

View File

@@ -1802,38 +1802,38 @@ export function StripeIcon(props: SVGProps<SVGSVGElement>) {
fillRule='evenodd'
clipRule='evenodd'
d='M360 78.0002C360 52.4002 347.6 32.2002 323.9 32.2002C300.1 32.2002 285.7 52.4002 285.7 77.8002C285.7 107.9 302.7 123.1 327.1 123.1C339 123.1 348 120.4 354.8 116.6V96.6002C348 100 340.2 102.1 330.3 102.1C320.6 102.1 312 98.7002 310.9 86.9002H359.8C359.8 85.6002 360 80.4002 360 78.0002ZM310.6 68.5002C310.6 57.2002 317.5 52.5002 323.8 52.5002C329.9 52.5002 336.4 57.2002 336.4 68.5002H310.6Z'
fill='white'
fill='currentColor'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M247.1 32.2002C237.3 32.2002 231 36.8002 227.5 40.0002L226.2 33.8002H204.2V150.4L229.2 145.1L229.3 116.8C232.9 119.4 238.2 123.1 247 123.1C264.9 123.1 281.2 108.7 281.2 77.0002C281.1 48.0002 264.6 32.2002 247.1 32.2002ZM241.1 101.1C235.2 101.1 231.7 99.0002 229.3 96.4002L229.2 59.3002C231.8 56.4002 235.4 54.4002 241.1 54.4002C250.2 54.4002 256.5 64.6002 256.5 77.7002C256.5 91.1002 250.3 101.1 241.1 101.1Z'
fill='white'
fill='currentColor'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M169.8 26.3001L194.9 20.9001V0.600098L169.8 5.9001V26.3001Z'
fill='white'
fill='currentColor'
/>
<path d='M194.9 33.9001H169.8V121.4H194.9V33.9001Z' fill='white' />
<path d='M194.9 33.9001H169.8V121.4H194.9V33.9001Z' fill='currentColor' />
<path
fillRule='evenodd'
clipRule='evenodd'
d='M142.9 41.3001L141.3 33.9001H119.7V121.4H144.7V62.1001C150.6 54.4001 160.6 55.8001 163.7 56.9001V33.9001C160.5 32.7001 148.8 30.5001 142.9 41.3001Z'
fill='white'
fill='currentColor'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M92.8999 12.2002L68.4999 17.4002L68.3999 97.5002C68.3999 112.3 79.4999 123.2 94.2999 123.2C102.5 123.2 108.5 121.7 111.8 119.9V99.6002C108.6 100.9 92.7999 105.5 92.7999 90.7002V55.2002H111.8V33.9002H92.7999L92.8999 12.2002Z'
fill='white'
fill='currentColor'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M25.3 59.3002C25.3 55.4002 28.5 53.9002 33.8 53.9002C41.4 53.9002 51 56.2002 58.6 60.3002V36.8002C50.3 33.5002 42.1 32.2002 33.8 32.2002C13.5 32.2002 0 42.8002 0 60.5002C0 88.1002 38 83.7002 38 95.6002C38 100.2 34 101.7 28.4 101.7C20.1 101.7 9.5 98.3002 1.1 93.7002V117.5C10.4 121.5 19.8 123.2 28.4 123.2C49.2 123.2 63.5 112.9 63.5 95.0002C63.4 65.2002 25.3 70.5002 25.3 59.3002Z'
fill='white'
fill='currentColor'
/>
</svg>
)
@@ -4032,3 +4032,55 @@ export function ApolloIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function Neo4jIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 128 128' fill='currentColor' xmlns='http://www.w3.org/2000/svg'>
<path
d='M63.333 32.567c-5.2.866-9.566 3-12.833 6.266-3.867 3.867-5.833 8.5-6.5 15.367-.3 3.133-.467 15.467-.2 15.467.067 0 .7-.234 1.4-.534 1.633-.7 5.167-.7 7-.033l1.4.5.167-8.033c.166-8.567.366-9.867 1.966-13.067 1.1-2.133 3.767-4.633 6.034-5.667 2.6-1.2 6.4-1.666 9.333-1.2 6.267 1.034 10 4.434 11.567 10.5.633 2.434.666 3.7.666 17.1v14.434H93.4L93.233 67.9c-.1-14.9-.166-15.9-.866-18.567-1.9-7.4-6.5-12.766-12.934-15.2-3.433-1.3-6.7-1.8-11.2-1.766-2.233.033-4.433.133-4.9.2z'
fill='#000'
/>
<path
d='M22.733 57.2c-2.866 1.433-4.4 4-4.4 7.467 0 1.1.2 2.5.467 3.133.633 1.567 2.433 3.467 4 4.3 1.9 1 5.5 1 7.367.033l1.366-.7 4.267 2.9 4.267 2.934V81.7L35.8 84.633l-4.3 2.934-1.1-.667c-1.6-.933-4.7-1.133-6.6-.4-2 .767-4.067 2.6-4.833 4.333-.834 1.767-.834 5.234 0 7 .7 1.567 2.333 3.3 3.8 4.067.6.3 2.033.6 3.233.7 2.8.2 5.167-.733 6.867-2.733 1.366-1.6 2.266-4.4 2.033-6.334l-.167-1.366 4.3-2.9 4.3-2.9 1.534.7c2.333 1 5.8.766 8-.567 2.4-1.5 3.6-3.633 3.733-6.633.1-2.1 0-2.567-.833-4.2-2.167-4.134-7-5.7-11.134-3.634l-1.233.6-4.233-2.9-4.234-2.9-.1-2.333c-.066-2.8-.866-4.6-2.833-6.233-2.5-2.134-6.233-2.567-9.267-1.067z'
fill='#018BFF'
/>
</svg>
)
}
export function CalendlyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 512 512'
xmlns='http://www.w3.org/2000/svg'
fillRule='evenodd'
clipRule='evenodd'
strokeLinejoin='round'
strokeMiterlimit='2'
>
<g fillRule='nonzero'>
<path
d='M346.955 330.224c-15.875 14.088-35.7 31.619-71.647 31.619h-21.495c-26.012 0-49.672-9.455-66.607-26.593-16.543-16.747-25.649-39.665-25.649-64.545v-29.41c0-24.88 9.106-47.799 25.65-64.545 16.934-17.138 40.594-26.579 66.606-26.579h21.495c35.99 0 55.772 17.516 71.647 31.604 16.484 14.524 30.703 27.218 68.625 27.218a109.162 109.162 0 0017.269-1.38l-.13-.334a129.909 129.909 0 00-7.974-16.382L399.4 146.99c-23.232-40.234-66.304-65.098-112.763-65.096h-50.703c-46.46-.002-89.531 24.862-112.764 65.096l-25.344 43.906c-23.224 40.238-23.224 89.968 0 130.206l25.344 43.906c23.233 40.234 66.305 65.098 112.764 65.096h50.703c46.459.002 89.53-24.862 112.763-65.096l25.345-43.833a129.909 129.909 0 007.973-16.383l.13-.32a107.491 107.491 0 00-17.268-1.452c-37.922 0-52.14 12.621-68.625 27.218'
fill='#006bff'
/>
<path
d='M275.308 176.823h-21.495c-39.592 0-65.605 28.278-65.605 64.471v29.411c0 36.194 26.013 64.472 65.605 64.472h21.495c57.69 0 53.158-58.822 140.272-58.822 8.254-.009 16.49.75 24.603 2.266a130.047 130.047 0 000-45.242 134.431 134.431 0 01-24.603 2.266c-87.143 0-82.583-58.822-140.272-58.822'
fill='#006bff'
/>
<path
d='M490.233 300.116a121.451 121.451 0 00-50.035-21.51v.436a130.296 130.296 0 01-7.262 25.344 95.25 95.25 0 0141.364 17.037c0 .116-.072.261-.116.392-28.788 93.217-115.55 157.228-213.112 157.228-122.358 0-223.044-100.685-223.044-223.043S138.714 32.956 261.072 32.956c97.561 0 184.324 64.012 213.112 157.229 0 .13.073.276.116.392a95.073 95.073 0 01-41.364 17.022 131.112 131.112 0 017.262 25.373 3.166 3.166 0 000 .407 121.415 121.415 0 0050.035-21.495c14.262-10.56 11.503-22.483 9.339-29.542C467.34 77.803 370.064 6 260.67 6c-137.147 0-250 112.854-250 250 0 137.146 112.853 250 250 250 109.394 0 206.67-71.803 238.902-176.342 2.164-7.059 4.923-18.983-9.34-29.542'
fill='#006bff'
/>
<path
d='M432.849 207.599a107.491 107.491 0 01-17.269 1.452c-37.922 0-52.14-12.62-68.61-27.217-15.89-14.089-35.672-31.619-71.662-31.619h-21.495c-26.027 0-49.672 9.455-66.607 26.593-16.543 16.746-25.649 39.665-25.649 64.545v29.41c0 24.88 9.106 47.799 25.65 64.545 16.934 17.138 40.579 26.578 66.606 26.578h21.495c35.99 0 55.772-17.515 71.661-31.604 16.47-14.524 30.69-27.217 68.611-27.217 5.783.001 11.558.463 17.269 1.38a129.303 129.303 0 007.262-25.345c.009-.145.009-.29 0-.436a134.301 134.301 0 00-24.604-2.25c-87.143 0-82.583 58.836-140.271 58.836H253.74c-39.592 0-65.604-28.293-65.604-64.487v-29.469c0-36.193 26.012-64.471 65.604-64.471h21.496c57.688 0 53.157 58.807 140.271 58.807 8.254.015 16.49-.74 24.604-2.251v-.407a131.112 131.112 0 00-7.262-25.373'
fill='#0ae8f0'
/>
<path
d='M432.849 207.599a107.491 107.491 0 01-17.269 1.452c-37.922 0-52.14-12.62-68.61-27.217-15.89-14.089-35.672-31.619-71.662-31.619h-21.495c-26.027 0-49.672 9.455-66.607 26.593-16.543 16.746-25.649 39.665-25.649 64.545v29.41c0 24.88 9.106 47.799 25.65 64.545 16.934 17.138 40.579 26.578 66.606 26.578h21.495c35.99 0 55.772-17.515 71.661-31.604 16.47-14.524 30.69-27.217 68.611-27.217 5.783.001 11.558.463 17.269 1.38a129.303 129.303 0 007.262-25.345c.009-.145.009-.29 0-.436a134.301 134.301 0 00-24.604-2.25c-87.143 0-82.583 58.836-140.271 58.836H253.74c-39.592 0-65.604-28.293-65.604-64.487v-29.469c0-36.193 26.012-64.471 65.604-64.471h21.496c57.688 0 53.157 58.807 140.271 58.807 8.254.015 16.49-.74 24.604-2.251v-.407a131.112 131.112 0 00-7.262-25.373'
fill='#0ae8f0'
/>
</g>
</svg>
)
}

View File

@@ -10,6 +10,7 @@ import {
AsanaIcon,
BrainIcon,
BrowserUseIcon,
CalendlyIcon,
ClayIcon,
ConfluenceIcon,
DiscordIcon,
@@ -44,6 +45,7 @@ import {
MistralIcon,
MongoDBIcon,
MySQLIcon,
Neo4jIcon,
NotionIcon,
OpenAIIcon,
OutlookIcon,
@@ -118,6 +120,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
openai: OpenAIIcon,
onedrive: MicrosoftOneDriveIcon,
notion: NotionIcon,
neo4j: Neo4jIcon,
mysql: MySQLIcon,
mongodb: MongoDBIcon,
mistral_parse: MistralIcon,
@@ -151,6 +154,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
discord: DiscordIcon,
confluence: ConfluenceIcon,
clay: ClayIcon,
calendly: CalendlyIcon,
browser_use: BrowserUseIcon,
asana: AsanaIcon,
arxiv: ArxivIcon,

View File

@@ -0,0 +1,163 @@
---
title: Calendly
description: Manage Calendly scheduling and events
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="calendly"
color="#FFFFFF"
/>
## Usage Instructions
Integrate Calendly into your workflow. Manage event types, scheduled events, invitees, and webhooks. Can also trigger workflows based on Calendly webhook events (invitee scheduled, invitee canceled, routing form submitted). Requires Personal Access Token.
## Tools
### `calendly_get_current_user`
Get information about the currently authenticated Calendly user
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Calendly Personal Access Token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `resource` | object | Current user information |
### `calendly_list_event_types`
Retrieve a list of all event types for a user or organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Calendly Personal Access Token |
| `user` | string | No | Return only event types that belong to this user \(URI format\) |
| `organization` | string | No | Return only event types that belong to this organization \(URI format\) |
| `count` | number | No | Number of results per page \(default: 20, max: 100\) |
| `pageToken` | string | No | Page token for pagination |
| `sort` | string | No | Sort order for results \(e.g., "name:asc", "name:desc"\) |
| `active` | boolean | No | When true, show only active event types. When false or unchecked, show all event types \(both active and inactive\). |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `collection` | array | Array of event type objects |
### `calendly_get_event_type`
Get detailed information about a specific event type
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Calendly Personal Access Token |
| `eventTypeUuid` | string | Yes | Event type UUID \(can be full URI or just the UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `resource` | object | Event type details |
### `calendly_list_scheduled_events`
Retrieve a list of scheduled events for a user or organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Calendly Personal Access Token |
| `user` | string | No | Return events that belong to this user \(URI format\). Either "user" or "organization" must be provided. |
| `organization` | string | No | Return events that belong to this organization \(URI format\). Either "user" or "organization" must be provided. |
| `invitee_email` | string | No | Return events where invitee has this email |
| `count` | number | No | Number of results per page \(default: 20, max: 100\) |
| `max_start_time` | string | No | Return events with start time before this time \(ISO 8601 format\) |
| `min_start_time` | string | No | Return events with start time after this time \(ISO 8601 format\) |
| `pageToken` | string | No | Page token for pagination |
| `sort` | string | No | Sort order for results \(e.g., "start_time:asc", "start_time:desc"\) |
| `status` | string | No | Filter by status \("active" or "canceled"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `collection` | array | Array of scheduled event objects |
### `calendly_get_scheduled_event`
Get detailed information about a specific scheduled event
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Calendly Personal Access Token |
| `eventUuid` | string | Yes | Scheduled event UUID \(can be full URI or just the UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `resource` | object | Scheduled event details |
### `calendly_list_event_invitees`
Retrieve a list of invitees for a scheduled event
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Calendly Personal Access Token |
| `eventUuid` | string | Yes | Scheduled event UUID \(can be full URI or just the UUID\) |
| `count` | number | No | Number of results per page \(default: 20, max: 100\) |
| `email` | string | No | Filter invitees by email address |
| `pageToken` | string | No | Page token for pagination |
| `sort` | string | No | Sort order for results \(e.g., "created_at:asc", "created_at:desc"\) |
| `status` | string | No | Filter by status \("active" or "canceled"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `collection` | array | Array of invitee objects |
### `calendly_cancel_event`
Cancel a scheduled event
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Calendly Personal Access Token |
| `eventUuid` | string | Yes | Scheduled event UUID to cancel \(can be full URI or just the UUID\) |
| `reason` | string | No | Reason for cancellation \(will be sent to invitees\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `resource` | object | Cancellation details |
## Notes
- Category: `tools`
- Type: `calendly`

View File

@@ -26,9 +26,11 @@ Add a new memory to the database or append to existing memory with the same ID.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | string | Yes | Identifier for the memory. If a memory with this ID already exists, the new data will be appended to it. |
| `conversationId` | string | No | Conversation identifier \(e.g., user-123, session-abc\). If a memory with this conversationId already exists for this block, the new message will be appended to it. |
| `id` | string | No | Legacy parameter for conversation identifier. Use conversationId instead. Provided for backwards compatibility. |
| `role` | string | Yes | Role for agent memory \(user, assistant, or system\) |
| `content` | string | Yes | Content for agent memory |
| `blockId` | string | No | Optional block ID. If not provided, uses the current block ID from execution context, or defaults to "default". |
#### Output
@@ -40,20 +42,23 @@ Add a new memory to the database or append to existing memory with the same ID.
### `memory_get`
Retrieve a specific memory by its ID
Retrieve memory by conversationId, blockId, blockName, or a combination. Returns all matching memories.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | string | Yes | Identifier for the memory to retrieve |
| `conversationId` | string | No | Conversation identifier \(e.g., user-123, session-abc\). If provided alone, returns all memories for this conversation across all blocks. |
| `id` | string | No | Legacy parameter for conversation identifier. Use conversationId instead. Provided for backwards compatibility. |
| `blockId` | string | No | Block identifier. If provided alone, returns all memories for this block across all conversations. If provided with conversationId, returns memories for that specific conversation in this block. |
| `blockName` | string | No | Block name. Alternative to blockId. If provided alone, returns all memories for blocks with this name. If provided with conversationId, returns memories for that conversation in blocks with this name. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the memory was retrieved successfully |
| `memories` | array | Array of memory data for the requested ID |
| `memories` | array | Array of memory objects with conversationId, blockId, blockName, and data fields |
| `message` | string | Success or error message |
| `error` | string | Error message if operation failed |
@@ -71,19 +76,22 @@ Retrieve all memories from the database
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether all memories were retrieved successfully |
| `memories` | array | Array of all memory objects with keys, types, and data |
| `memories` | array | Array of all memory objects with key, conversationId, blockId, blockName, and data fields |
| `message` | string | Success or error message |
| `error` | string | Error message if operation failed |
### `memory_delete`
Delete a specific memory by its ID
Delete memories by conversationId, blockId, blockName, or a combination. Supports bulk deletion.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | string | Yes | Identifier for the memory to delete |
| `conversationId` | string | No | Conversation identifier \(e.g., user-123, session-abc\). If provided alone, deletes all memories for this conversation across all blocks. |
| `id` | string | No | Legacy parameter for conversation identifier. Use conversationId instead. Provided for backwards compatibility. |
| `blockId` | string | No | Block identifier. If provided alone, deletes all memories for this block across all conversations. If provided with conversationId, deletes memories for that specific conversation in this block. |
| `blockName` | string | No | Block name. Alternative to blockId. If provided alone, deletes all memories for blocks with this name. If provided with conversationId, deletes memories for that conversation in blocks with this name. |
#### Output

View File

@@ -6,6 +6,7 @@
"arxiv",
"asana",
"browser_use",
"calendly",
"clay",
"confluence",
"discord",
@@ -39,6 +40,7 @@
"mistral_parse",
"mongodb",
"mysql",
"neo4j",
"notion",
"onedrive",
"openai",

View File

@@ -0,0 +1,176 @@
---
title: Neo4j
description: Connect to Neo4j graph database
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="neo4j"
color="#FFFFFF"
/>
## Usage Instructions
Integrate Neo4j graph database into the workflow. Can query, create, merge, update, and delete nodes and relationships.
## Tools
### `neo4j_query`
Execute MATCH queries to read nodes and relationships from Neo4j graph database. For best performance and to prevent large result sets, include LIMIT in your query (e.g.,
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Neo4j server hostname or IP address |
| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Neo4j username |
| `password` | string | Yes | Neo4j password |
| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) |
| `cypherQuery` | string | Yes | Cypher query to execute \(typically MATCH statements\) |
| `parameters` | object | No | Parameters for the Cypher query as a JSON object. Use for any dynamic values including LIMIT \(e.g., query: "MATCH \(n\) RETURN n LIMIT $limit", parameters: \{limit: 100\}\). |
| `parameters` | string | No | No description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `records` | array | Array of records returned from the query |
| `recordCount` | number | Number of records returned |
| `summary` | json | Query execution summary with timing and counters |
### `neo4j_create`
Execute CREATE statements to add new nodes and relationships to Neo4j graph database
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Neo4j server hostname or IP address |
| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Neo4j username |
| `password` | string | Yes | Neo4j password |
| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) |
| `cypherQuery` | string | Yes | Cypher CREATE statement to execute |
| `parameters` | object | No | Parameters for the Cypher query as a JSON object |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `summary` | json | Creation summary with counters for nodes and relationships created |
### `neo4j_merge`
Execute MERGE statements to find or create nodes and relationships in Neo4j (upsert operation)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Neo4j server hostname or IP address |
| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Neo4j username |
| `password` | string | Yes | Neo4j password |
| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) |
| `cypherQuery` | string | Yes | Cypher MERGE statement to execute |
| `parameters` | object | No | Parameters for the Cypher query as a JSON object |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `summary` | json | Merge summary with counters for nodes/relationships created or matched |
### `neo4j_update`
Execute SET statements to update properties of existing nodes and relationships in Neo4j
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Neo4j server hostname or IP address |
| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Neo4j username |
| `password` | string | Yes | Neo4j password |
| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) |
| `cypherQuery` | string | Yes | Cypher query with MATCH and SET statements to update properties |
| `parameters` | object | No | Parameters for the Cypher query as a JSON object |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `summary` | json | Update summary with counters for properties set |
### `neo4j_delete`
Execute DELETE or DETACH DELETE statements to remove nodes and relationships from Neo4j
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Neo4j server hostname or IP address |
| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Neo4j username |
| `password` | string | Yes | Neo4j password |
| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) |
| `cypherQuery` | string | Yes | Cypher query with MATCH and DELETE/DETACH DELETE statements |
| `parameters` | object | No | Parameters for the Cypher query as a JSON object |
| `detach` | boolean | No | Whether to use DETACH DELETE to remove relationships before deleting nodes |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `summary` | json | Delete summary with counters for nodes and relationships deleted |
### `neo4j_execute`
Execute arbitrary Cypher queries on Neo4j graph database for complex operations
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Neo4j server hostname or IP address |
| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) |
| `database` | string | Yes | Database name to connect to |
| `username` | string | Yes | Neo4j username |
| `password` | string | Yes | Neo4j password |
| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) |
| `cypherQuery` | string | Yes | Cypher query to execute \(any valid Cypher statement\) |
| `parameters` | object | No | Parameters for the Cypher query as a JSON object |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `records` | array | Array of records returned from the query |
| `recordCount` | number | Number of records returned |
| `summary` | json | Execution summary with timing and counters |
## Notes
- Category: `tools`
- Type: `neo4j`

View File

@@ -1,7 +1,6 @@
import * as Icons from '@/components/icons'
import { inter } from '@/app/fonts/inter/inter'
// AI models and providers
const modelProviderIcons = [
{ icon: Icons.OpenAIIcon, label: 'OpenAI' },
{ icon: Icons.AnthropicIcon, label: 'Anthropic' },
@@ -16,7 +15,6 @@ const modelProviderIcons = [
{ icon: Icons.ElevenLabsIcon, label: 'ElevenLabs' },
]
// Communication and productivity tools
const communicationIcons = [
{ icon: Icons.SlackIcon, label: 'Slack' },
{ icon: Icons.GmailIcon, label: 'Gmail' },
@@ -28,6 +26,7 @@ const communicationIcons = [
{ icon: Icons.ConfluenceIcon, label: 'Confluence' },
{ icon: Icons.TelegramIcon, label: 'Telegram' },
{ icon: Icons.GoogleCalendarIcon, label: 'Google Calendar' },
{ icon: Icons.CalendlyIcon, label: 'Calendly' },
{ icon: Icons.GoogleDocsIcon, label: 'Google Docs' },
{ icon: Icons.BrowserUseIcon, label: 'BrowserUse' },
{ icon: Icons.TypeformIcon, label: 'Typeform' },
@@ -37,7 +36,6 @@ const communicationIcons = [
{ icon: Icons.AirtableIcon, label: 'Airtable' },
]
// Data, storage and search services
const dataStorageIcons = [
{ icon: Icons.PineconeIcon, label: 'Pinecone' },
{ icon: Icons.SupabaseIcon, label: 'Supabase' },

View File

@@ -0,0 +1,118 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
validateCypherQuery,
} from '@/app/api/tools/neo4j/utils'
const logger = createLogger('Neo4jCreateAPI')
const CreateSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
encryption: z.enum(['enabled', 'disabled']).default('disabled'),
cypherQuery: z.string().min(1, 'Cypher query is required'),
parameters: z.record(z.unknown()).nullable().optional().default({}),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let driver = null
let session = null
try {
const body = await request.json()
const params = CreateSchema.parse(body)
logger.info(
`[${requestId}] Executing Neo4j create on ${params.host}:${params.port}/${params.database}`
)
const validation = validateCypherQuery(params.cypherQuery)
if (!validation.isValid) {
logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
driver = await createNeo4jDriver({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption,
})
session = driver.session({ database: params.database })
const result = await session.run(params.cypherQuery, params.parameters)
const records = result.records.map((record) => {
const obj: Record<string, unknown> = {}
record.keys.forEach((key) => {
if (typeof key === 'string') {
obj[key] = convertNeo4jTypesToJSON(record.get(key))
}
})
return obj
})
const summary = {
resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(),
resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(),
counters: {
nodesCreated: result.summary.counters.updates().nodesCreated,
nodesDeleted: result.summary.counters.updates().nodesDeleted,
relationshipsCreated: result.summary.counters.updates().relationshipsCreated,
relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted,
propertiesSet: result.summary.counters.updates().propertiesSet,
labelsAdded: result.summary.counters.updates().labelsAdded,
labelsRemoved: result.summary.counters.updates().labelsRemoved,
indexesAdded: result.summary.counters.updates().indexesAdded,
indexesRemoved: result.summary.counters.updates().indexesRemoved,
constraintsAdded: result.summary.counters.updates().constraintsAdded,
constraintsRemoved: result.summary.counters.updates().constraintsRemoved,
},
}
logger.info(
`[${requestId}] Create executed successfully, created ${summary.counters.nodesCreated} nodes and ${summary.counters.relationshipsCreated} relationships, returned ${records.length} records`
)
return NextResponse.json({
message: `Created ${summary.counters.nodesCreated} nodes and ${summary.counters.relationshipsCreated} relationships`,
records,
recordCount: records.length,
summary,
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Neo4j create failed:`, error)
return NextResponse.json({ error: `Neo4j create failed: ${errorMessage}` }, { status: 500 })
} finally {
if (session) {
await session.close()
}
if (driver) {
await driver.close()
}
}
}

View File

@@ -0,0 +1,103 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import { createNeo4jDriver, validateCypherQuery } from '@/app/api/tools/neo4j/utils'
const logger = createLogger('Neo4jDeleteAPI')
const DeleteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
encryption: z.enum(['enabled', 'disabled']).default('disabled'),
cypherQuery: z.string().min(1, 'Cypher query is required'),
parameters: z.record(z.unknown()).nullable().optional().default({}),
detach: z.boolean().optional().default(false),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let driver = null
let session = null
try {
const body = await request.json()
const params = DeleteSchema.parse(body)
logger.info(
`[${requestId}] Executing Neo4j delete on ${params.host}:${params.port}/${params.database}`
)
const validation = validateCypherQuery(params.cypherQuery)
if (!validation.isValid) {
logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
driver = await createNeo4jDriver({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption,
})
session = driver.session({ database: params.database })
const result = await session.run(params.cypherQuery, params.parameters)
const summary = {
resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(),
resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(),
counters: {
nodesCreated: result.summary.counters.updates().nodesCreated,
nodesDeleted: result.summary.counters.updates().nodesDeleted,
relationshipsCreated: result.summary.counters.updates().relationshipsCreated,
relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted,
propertiesSet: result.summary.counters.updates().propertiesSet,
labelsAdded: result.summary.counters.updates().labelsAdded,
labelsRemoved: result.summary.counters.updates().labelsRemoved,
indexesAdded: result.summary.counters.updates().indexesAdded,
indexesRemoved: result.summary.counters.updates().indexesRemoved,
constraintsAdded: result.summary.counters.updates().constraintsAdded,
constraintsRemoved: result.summary.counters.updates().constraintsRemoved,
},
}
logger.info(
`[${requestId}] Delete executed successfully, deleted ${summary.counters.nodesDeleted} nodes and ${summary.counters.relationshipsDeleted} relationships`
)
return NextResponse.json({
message: `Deleted ${summary.counters.nodesDeleted} nodes and ${summary.counters.relationshipsDeleted} relationships`,
summary,
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Neo4j delete failed:`, error)
return NextResponse.json({ error: `Neo4j delete failed: ${errorMessage}` }, { status: 500 })
} finally {
if (session) {
await session.close()
}
if (driver) {
await driver.close()
}
}
}

View File

@@ -0,0 +1,116 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
validateCypherQuery,
} from '@/app/api/tools/neo4j/utils'
const logger = createLogger('Neo4jExecuteAPI')
const ExecuteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
encryption: z.enum(['enabled', 'disabled']).default('disabled'),
cypherQuery: z.string().min(1, 'Cypher query is required'),
parameters: z.record(z.unknown()).nullable().optional().default({}),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let driver = null
let session = null
try {
const body = await request.json()
const params = ExecuteSchema.parse(body)
logger.info(
`[${requestId}] Executing Neo4j query on ${params.host}:${params.port}/${params.database}`
)
const validation = validateCypherQuery(params.cypherQuery)
if (!validation.isValid) {
logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
driver = await createNeo4jDriver({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption,
})
session = driver.session({ database: params.database })
const result = await session.run(params.cypherQuery, params.parameters)
const records = result.records.map((record) => {
const obj: Record<string, unknown> = {}
record.keys.forEach((key) => {
if (typeof key === 'string') {
obj[key] = convertNeo4jTypesToJSON(record.get(key))
}
})
return obj
})
const summary = {
resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(),
resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(),
counters: {
nodesCreated: result.summary.counters.updates().nodesCreated,
nodesDeleted: result.summary.counters.updates().nodesDeleted,
relationshipsCreated: result.summary.counters.updates().relationshipsCreated,
relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted,
propertiesSet: result.summary.counters.updates().propertiesSet,
labelsAdded: result.summary.counters.updates().labelsAdded,
labelsRemoved: result.summary.counters.updates().labelsRemoved,
indexesAdded: result.summary.counters.updates().indexesAdded,
indexesRemoved: result.summary.counters.updates().indexesRemoved,
constraintsAdded: result.summary.counters.updates().constraintsAdded,
constraintsRemoved: result.summary.counters.updates().constraintsRemoved,
},
}
logger.info(`[${requestId}] Query executed successfully, returned ${records.length} records`)
return NextResponse.json({
message: `Query executed successfully, returned ${records.length} records`,
records,
recordCount: records.length,
summary,
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Neo4j execute failed:`, error)
return NextResponse.json({ error: `Neo4j execute failed: ${errorMessage}` }, { status: 500 })
} finally {
if (session) {
await session.close()
}
if (driver) {
await driver.close()
}
}
}

View File

@@ -0,0 +1,118 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
validateCypherQuery,
} from '@/app/api/tools/neo4j/utils'
const logger = createLogger('Neo4jMergeAPI')
const MergeSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
encryption: z.enum(['enabled', 'disabled']).default('disabled'),
cypherQuery: z.string().min(1, 'Cypher query is required'),
parameters: z.record(z.unknown()).nullable().optional().default({}),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let driver = null
let session = null
try {
const body = await request.json()
const params = MergeSchema.parse(body)
logger.info(
`[${requestId}] Executing Neo4j merge on ${params.host}:${params.port}/${params.database}`
)
const validation = validateCypherQuery(params.cypherQuery)
if (!validation.isValid) {
logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
driver = await createNeo4jDriver({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption,
})
session = driver.session({ database: params.database })
const result = await session.run(params.cypherQuery, params.parameters)
const records = result.records.map((record) => {
const obj: Record<string, unknown> = {}
record.keys.forEach((key) => {
if (typeof key === 'string') {
obj[key] = convertNeo4jTypesToJSON(record.get(key))
}
})
return obj
})
const summary = {
resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(),
resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(),
counters: {
nodesCreated: result.summary.counters.updates().nodesCreated,
nodesDeleted: result.summary.counters.updates().nodesDeleted,
relationshipsCreated: result.summary.counters.updates().relationshipsCreated,
relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted,
propertiesSet: result.summary.counters.updates().propertiesSet,
labelsAdded: result.summary.counters.updates().labelsAdded,
labelsRemoved: result.summary.counters.updates().labelsRemoved,
indexesAdded: result.summary.counters.updates().indexesAdded,
indexesRemoved: result.summary.counters.updates().indexesRemoved,
constraintsAdded: result.summary.counters.updates().constraintsAdded,
constraintsRemoved: result.summary.counters.updates().constraintsRemoved,
},
}
logger.info(
`[${requestId}] Merge executed successfully, created ${summary.counters.nodesCreated} nodes, ${summary.counters.relationshipsCreated} relationships, returned ${records.length} records`
)
return NextResponse.json({
message: `Merge completed: ${summary.counters.nodesCreated} nodes created, ${summary.counters.relationshipsCreated} relationships created`,
records,
recordCount: records.length,
summary,
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Neo4j merge failed:`, error)
return NextResponse.json({ error: `Neo4j merge failed: ${errorMessage}` }, { status: 500 })
} finally {
if (session) {
await session.close()
}
if (driver) {
await driver.close()
}
}
}

View File

@@ -0,0 +1,116 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
validateCypherQuery,
} from '@/app/api/tools/neo4j/utils'
const logger = createLogger('Neo4jQueryAPI')
const QuerySchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
encryption: z.enum(['enabled', 'disabled']).default('disabled'),
cypherQuery: z.string().min(1, 'Cypher query is required'),
parameters: z.record(z.unknown()).nullable().optional().default({}),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let driver = null
let session = null
try {
const body = await request.json()
const params = QuerySchema.parse(body)
logger.info(
`[${requestId}] Executing Neo4j query on ${params.host}:${params.port}/${params.database}`
)
const validation = validateCypherQuery(params.cypherQuery)
if (!validation.isValid) {
logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
driver = await createNeo4jDriver({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption,
})
session = driver.session({ database: params.database })
const result = await session.run(params.cypherQuery, params.parameters)
const records = result.records.map((record) => {
const obj: Record<string, unknown> = {}
record.keys.forEach((key) => {
if (typeof key === 'string') {
obj[key] = convertNeo4jTypesToJSON(record.get(key))
}
})
return obj
})
const summary = {
resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(),
resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(),
counters: {
nodesCreated: result.summary.counters.updates().nodesCreated,
nodesDeleted: result.summary.counters.updates().nodesDeleted,
relationshipsCreated: result.summary.counters.updates().relationshipsCreated,
relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted,
propertiesSet: result.summary.counters.updates().propertiesSet,
labelsAdded: result.summary.counters.updates().labelsAdded,
labelsRemoved: result.summary.counters.updates().labelsRemoved,
indexesAdded: result.summary.counters.updates().indexesAdded,
indexesRemoved: result.summary.counters.updates().indexesRemoved,
constraintsAdded: result.summary.counters.updates().constraintsAdded,
constraintsRemoved: result.summary.counters.updates().constraintsRemoved,
},
}
logger.info(`[${requestId}] Query executed successfully, returned ${records.length} records`)
return NextResponse.json({
message: `Found ${records.length} records`,
records,
recordCount: records.length,
summary,
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Neo4j query failed:`, error)
return NextResponse.json({ error: `Neo4j query failed: ${errorMessage}` }, { status: 500 })
} finally {
if (session) {
await session.close()
}
if (driver) {
await driver.close()
}
}
}

View File

@@ -0,0 +1,118 @@
import { randomUUID } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
validateCypherQuery,
} from '@/app/api/tools/neo4j/utils'
const logger = createLogger('Neo4jUpdateAPI')
const UpdateSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive('Port must be a positive integer'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
encryption: z.enum(['enabled', 'disabled']).default('disabled'),
cypherQuery: z.string().min(1, 'Cypher query is required'),
parameters: z.record(z.unknown()).nullable().optional().default({}),
})
export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
let driver = null
let session = null
try {
const body = await request.json()
const params = UpdateSchema.parse(body)
logger.info(
`[${requestId}] Executing Neo4j update on ${params.host}:${params.port}/${params.database}`
)
const validation = validateCypherQuery(params.cypherQuery)
if (!validation.isValid) {
logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`)
return NextResponse.json(
{ error: `Query validation failed: ${validation.error}` },
{ status: 400 }
)
}
driver = await createNeo4jDriver({
host: params.host,
port: params.port,
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption,
})
session = driver.session({ database: params.database })
const result = await session.run(params.cypherQuery, params.parameters)
const records = result.records.map((record) => {
const obj: Record<string, unknown> = {}
record.keys.forEach((key) => {
if (typeof key === 'string') {
obj[key] = convertNeo4jTypesToJSON(record.get(key))
}
})
return obj
})
const summary = {
resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(),
resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(),
counters: {
nodesCreated: result.summary.counters.updates().nodesCreated,
nodesDeleted: result.summary.counters.updates().nodesDeleted,
relationshipsCreated: result.summary.counters.updates().relationshipsCreated,
relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted,
propertiesSet: result.summary.counters.updates().propertiesSet,
labelsAdded: result.summary.counters.updates().labelsAdded,
labelsRemoved: result.summary.counters.updates().labelsRemoved,
indexesAdded: result.summary.counters.updates().indexesAdded,
indexesRemoved: result.summary.counters.updates().indexesRemoved,
constraintsAdded: result.summary.counters.updates().constraintsAdded,
constraintsRemoved: result.summary.counters.updates().constraintsRemoved,
},
}
logger.info(
`[${requestId}] Update executed successfully, ${summary.counters.propertiesSet} properties set, returned ${records.length} records`
)
return NextResponse.json({
message: `Updated ${summary.counters.propertiesSet} properties`,
records,
recordCount: records.length,
summary,
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] Neo4j update failed:`, error)
return NextResponse.json({ error: `Neo4j update failed: ${errorMessage}` }, { status: 500 })
} finally {
if (session) {
await session.close()
}
if (driver) {
await driver.close()
}
}
}

View File

@@ -0,0 +1,163 @@
import neo4j from 'neo4j-driver'
import type { Neo4jConnectionConfig } from '@/tools/neo4j/types'
export async function createNeo4jDriver(config: Neo4jConnectionConfig) {
const isAuraHost = config.host.includes('.databases.neo4j.io')
let protocol: string
if (isAuraHost) {
protocol = 'neo4j+s'
} else {
protocol = config.encryption === 'enabled' ? 'bolt+s' : 'bolt'
}
const uri = `${protocol}://${config.host}:${config.port}`
const driverConfig: any = {
maxConnectionPoolSize: 1,
connectionTimeout: 10000,
}
if (!protocol.endsWith('+s')) {
driverConfig.encrypted = config.encryption === 'enabled' ? 'ENCRYPTION_ON' : 'ENCRYPTION_OFF'
}
const driver = neo4j.driver(uri, neo4j.auth.basic(config.username, config.password), driverConfig)
await driver.verifyConnectivity()
return driver
}
export function validateCypherQuery(
query: string,
allowDangerousOps = false
): { isValid: boolean; error?: string } {
if (!query || typeof query !== 'string') {
return {
isValid: false,
error: 'Query must be a non-empty string',
}
}
if (!allowDangerousOps) {
const dangerousPatterns = [
/DROP\s+DATABASE/i,
/DROP\s+CONSTRAINT/i,
/DROP\s+INDEX/i,
/CREATE\s+DATABASE/i,
/CREATE\s+CONSTRAINT/i,
/CREATE\s+INDEX/i,
/CALL\s+dbms\./i,
/CALL\s+db\./i,
/LOAD\s+CSV/i,
/apoc\.cypher\.run/i,
/apoc\.load/i,
/apoc\.periodic/i,
]
for (const pattern of dangerousPatterns) {
if (pattern.test(query)) {
return {
isValid: false,
error:
'Query contains potentially dangerous operations (schema changes, system procedures, or external data loading)',
}
}
}
}
const trimmedQuery = query.trim()
if (trimmedQuery.length === 0) {
return {
isValid: false,
error: 'Query cannot be empty',
}
}
return { isValid: true }
}
export function sanitizeLabelName(name: string): string {
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) {
throw new Error(
'Invalid label name. Must start with a letter and contain only letters, numbers, and underscores.'
)
}
return name
}
export function sanitizePropertyKey(key: string): string {
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) {
throw new Error(
'Invalid property key. Must start with a letter and contain only letters, numbers, and underscores.'
)
}
return key
}
export function sanitizeRelationshipType(type: string): string {
if (!/^[A-Z][A-Z0-9_]*$/.test(type)) {
throw new Error(
'Invalid relationship type. Must start with an uppercase letter and contain only uppercase letters, numbers, and underscores.'
)
}
return type
}
export function convertNeo4jTypesToJSON(value: unknown): unknown {
if (value === null || value === undefined) {
return value
}
if (typeof value === 'object' && value !== null && 'toNumber' in value) {
return (value as any).toNumber()
}
if (Array.isArray(value)) {
return value.map(convertNeo4jTypesToJSON)
}
if (typeof value === 'object') {
const obj = value as any
if (obj.labels && obj.properties && obj.identity) {
return {
identity: obj.identity.toNumber ? obj.identity.toNumber() : obj.identity,
labels: obj.labels,
properties: convertNeo4jTypesToJSON(obj.properties),
}
}
if (obj.type && obj.properties && obj.identity && obj.start && obj.end) {
return {
identity: obj.identity.toNumber ? obj.identity.toNumber() : obj.identity,
start: obj.start.toNumber ? obj.start.toNumber() : obj.start,
end: obj.end.toNumber ? obj.end.toNumber() : obj.end,
type: obj.type,
properties: convertNeo4jTypesToJSON(obj.properties),
}
}
if (obj.start && obj.end && obj.segments) {
return {
start: convertNeo4jTypesToJSON(obj.start),
end: convertNeo4jTypesToJSON(obj.end),
segments: obj.segments.map((seg: any) => ({
start: convertNeo4jTypesToJSON(seg.start),
relationship: convertNeo4jTypesToJSON(seg.relationship),
end: convertNeo4jTypesToJSON(seg.end),
})),
length: obj.length,
}
}
const result: Record<string, unknown> = {}
for (const [key, val] of Object.entries(obj)) {
result[key] = convertNeo4jTypesToJSON(val)
}
return result
}
return value
}

View File

@@ -297,6 +297,31 @@ export async function POST(request: NextRequest) {
}
}
if (provider === 'calendly') {
logger.info(`[${requestId}] Creating Calendly subscription before saving to database`)
try {
externalSubscriptionId = await createCalendlyWebhookSubscription(
request,
userId,
createTempWebhookData(),
requestId
)
if (externalSubscriptionId) {
resolvedProviderConfig.externalId = externalSubscriptionId
externalSubscriptionCreated = true
}
} catch (err) {
logger.error(`[${requestId}] Error creating Calendly webhook subscription`, err)
return NextResponse.json(
{
error: 'Failed to create webhook in Calendly',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
if (provider === 'microsoft-teams') {
const { createTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers')
logger.info(`[${requestId}] Creating Teams subscription before saving to database`)
@@ -635,6 +660,140 @@ async function createAirtableWebhookSubscription(
throw error
}
}
// Helper function to create the webhook subscription in Calendly
async function createCalendlyWebhookSubscription(
request: NextRequest,
userId: string,
webhookData: any,
requestId: string
): Promise<string | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, organization, triggerId } = providerConfig || {}
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Calendly webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.'
)
}
if (!organization) {
logger.warn(`[${requestId}] Missing organization URI for Calendly webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.'
)
}
if (!triggerId) {
logger.warn(`[${requestId}] Missing triggerId for Calendly webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error('Trigger ID is required to create Calendly webhook')
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
// Map trigger IDs to Calendly event types
const eventTypeMap: Record<string, string[]> = {
calendly_invitee_created: ['invitee.created'],
calendly_invitee_canceled: ['invitee.canceled'],
calendly_routing_form_submitted: ['routing_form_submission.created'],
calendly_webhook: ['invitee.created', 'invitee.canceled', 'routing_form_submission.created'],
}
const events = eventTypeMap[triggerId] || ['invitee.created']
const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions'
const requestBody = {
url: notificationUrl,
events,
organization,
scope: 'organization',
}
const calendlyResponse = await fetch(calendlyApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
if (!calendlyResponse.ok) {
const errorBody = await calendlyResponse.json().catch(() => ({}))
const errorMessage = errorBody.message || errorBody.title || 'Unknown Calendly API error'
logger.error(
`[${requestId}] Failed to create webhook in Calendly for webhook ${webhookData.id}. Status: ${calendlyResponse.status}`,
{ response: errorBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Calendly'
if (calendlyResponse.status === 401) {
userFriendlyMessage =
'Calendly authentication failed. Please verify your Personal Access Token is correct.'
} else if (calendlyResponse.status === 403) {
userFriendlyMessage =
'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.'
} else if (calendlyResponse.status === 404) {
userFriendlyMessage =
'Calendly organization not found. Please verify the Organization URI is correct.'
} else if (errorMessage && errorMessage !== 'Unknown Calendly API error') {
userFriendlyMessage = `Calendly error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
const responseBody = await calendlyResponse.json()
const webhookUri = responseBody.resource?.uri
if (!webhookUri) {
logger.error(
`[${requestId}] Calendly webhook created but no webhook URI returned for webhook ${webhookData.id}`,
{ response: responseBody }
)
throw new Error('Calendly webhook creation succeeded but no webhook URI was returned')
}
// Extract the webhook ID from the URI (e.g., https://api.calendly.com/webhook_subscriptions/WEBHOOK_ID)
const webhookId = webhookUri.split('/').pop()
if (!webhookId) {
logger.error(`[${requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, {
response: responseBody,
})
throw new Error('Failed to extract webhook ID from Calendly response')
}
logger.info(
`[${requestId}] Successfully created webhook in Calendly for webhook ${webhookData.id}.`,
{
calendlyWebhookUri: webhookUri,
calendlyWebhookId: webhookId,
}
)
return webhookId
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Calendly webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
// Re-throw the error so it can be caught by the outer try-catch
throw error
}
}
// Helper function to create the webhook subscription in Webflow
async function createWebflowWebhookSubscription(
request: NextRequest,

View File

@@ -577,10 +577,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
if (isStreamClosed) return
try {
logger.info(`[${requestId}] 📤 Sending SSE event:`, {
type: event.type,
data: event.data,
})
controller.enqueue(encodeSSEEvent(event))
} catch {
isStreamClosed = true

View File

@@ -359,7 +359,7 @@ export function OutputSelect({
<div ref={triggerRef} className='min-w-0 max-w-full'>
<Badge
variant='outline'
className='min-w-0 max-w-full cursor-pointer rounded-[6px]'
className='min-w-0 max-w-full cursor-pointer rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] dark:bg-[var(--surface-9)]'
title='Select outputs'
aria-expanded={open}
onMouseDown={(e) => {
@@ -378,7 +378,7 @@ export function OutputSelect({
align={align}
sideOffset={4}
maxHeight={maxHeight}
maxWidth={160}
maxWidth={300}
minWidth={160}
disablePortal={disablePopoverPortal}
onKeyDown={handleKeyDown}

View File

@@ -2,23 +2,19 @@
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { Input, Label, Textarea } from '@/components/emcn'
import {
Alert,
AlertDescription,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Card,
CardContent,
ImageUpload,
Skeleton,
} from '@/components/ui'
Button,
Input,
Label,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
Textarea,
} from '@/components/emcn'
import { Alert, AlertDescription, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
@@ -223,10 +219,6 @@ export function ChatDeploy({
throw new Error(error.error || 'Failed to delete chat')
}
if (onUndeploy) {
await onUndeploy()
}
setExistingChat(null)
setImageUrl(null)
setImageUploadError(null)
@@ -259,42 +251,42 @@ export function ChatDeploy({
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chat?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>
{getEmailDomain()}/chat/{existingChat?.identifier}
</span>{' '}
and undeploy the workflow.
<span className='mt-2 block'>
All users will lose access immediately, and this action cannot be undone.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
<Modal open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete Chat?</ModalTitle>
<ModalDescription>
This will delete your chat deployment at "{getEmailDomain()}/chat/
{existingChat?.identifier}". All users will lose access to the chat interface. You
can recreate this chat deployment at any time.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
variant='outline'
className='h-[32px] px-[12px]'
onClick={() => setShowDeleteConfirmation(false)}
disabled={isDeleting}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
</Button>
<Button
onClick={handleDelete}
disabled={isDeleting}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
{isDeleting ? (
<span className='flex items-center'>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
<>
<Loader2 className='mr-2 h-3.5 w-3.5 animate-spin' />
Deleting...
</span>
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
@@ -335,13 +327,12 @@ export function ChatDeploy({
onChange={(e) => updateField('title', e.target.value)}
required
disabled={chatSubmitting}
className='h-10 rounded-[8px]'
/>
{errors.title && <p className='text-destructive text-sm'>{errors.title}</p>}
</div>
<div className='space-y-2'>
<Label htmlFor='description' className='font-medium text-sm'>
Description (Optional)
Description
</Label>
<Textarea
id='description'
@@ -350,13 +341,11 @@ export function ChatDeploy({
onChange={(e) => updateField('description', e.target.value)}
rows={3}
disabled={chatSubmitting}
className='min-h-[80px] resize-none rounded-[8px]'
className='min-h-[80px] resize-none'
/>
</div>
<div className='space-y-2'>
<Label className='font-medium text-sm'>Chat Output</Label>
<Card className='rounded-[8px] border-input shadow-none'>
<CardContent className='p-1'>
<OutputSelect
workflowId={workflowId}
selectedOutputs={formData.selectedOutputBlocks}
@@ -364,12 +353,10 @@ export function ChatDeploy({
placeholder='Select which block outputs to use'
disabled={chatSubmitting}
/>
</CardContent>
</Card>
{errors.outputBlocks && (
<p className='text-destructive text-sm'>{errors.outputBlocks}</p>
)}
<p className='mt-2 text-muted-foreground text-xs'>
<p className='mt-2 text-[11px] text-[var(--text-secondary)]'>
Select which block's output to return to the user in the chat interface
</p>
</div>
@@ -396,14 +383,14 @@ export function ChatDeploy({
onChange={(e) => updateField('welcomeMessage', e.target.value)}
rows={3}
disabled={chatSubmitting}
className='min-h-[80px] resize-none rounded-[8px]'
className='min-h-[80px] resize-none'
/>
<p className='text-muted-foreground text-xs'>
This message will be displayed when users first open the chat
</p>
</div>
<div className='space-y-2'>
{/* <div className='space-y-2'>
<Label className='font-medium text-sm'>Chat Logo</Label>
<ImageUpload
value={imageUrl}
@@ -424,7 +411,7 @@ export function ChatDeploy({
Upload a logo for your chat (PNG, JPEG - max 5MB)
</p>
)}
</div>
</div> */}
<button
type='button'
@@ -435,42 +422,42 @@ export function ChatDeploy({
</div>
</form>
<AlertDialog open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chat?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>
{getEmailDomain()}/chat/{existingChat?.identifier}
</span>{' '}
and undeploy the workflow.
<span className='mt-2 block'>
All users will lose access immediately, and this action cannot be undone.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
<Modal open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete Chat?</ModalTitle>
<ModalDescription>
This will delete your chat deployment at "{getEmailDomain()}/chat/
{existingChat?.identifier}". All users will lose access to the chat interface. You can
recreate this chat deployment at any time.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
variant='outline'
className='h-[32px] px-[12px]'
onClick={() => setShowDeleteConfirmation(false)}
disabled={isDeleting}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
</Button>
<Button
onClick={handleDelete}
disabled={isDeleting}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
{isDeleting ? (
<span className='flex items-center'>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
<>
<Loader2 className='mr-2 h-3.5 w-3.5 animate-spin' />
Deleting...
</span>
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,8 +1,8 @@
import { useState } from 'react'
import { Check, Copy, Eye, EyeOff, Plus, RefreshCw } from 'lucide-react'
import { Input, Label } from '@/components/emcn'
import { Button, Input, Label } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Button, Card, CardContent } from '@/components/ui'
import { Card, CardContent } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/env'
import { cn, generatePassword } from '@/lib/utils'
import type { AuthType } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'
@@ -83,13 +83,13 @@ export function AuthSelector({
<Card
key={type}
className={cn(
'cursor-pointer overflow-hidden rounded-[8px] shadow-none transition-all duration-200 hover:bg-accent/30',
'cursor-pointer overflow-hidden rounded-[4px] shadow-none transition-all duration-200',
authType === type
? 'border border-muted-foreground hover:bg-accent/50'
: 'border border-input'
? 'border border-[#727272] bg-[var(--border-strong)] dark:border-[#727272] dark:bg-[var(--border-strong)]'
: 'border border-[var(--surface-11)] bg-[var(--surface-6)] hover:bg-[var(--surface-9)] dark:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-11)]'
)}
>
<CardContent className='relative flex flex-col items-center justify-center p-4 text-center'>
<CardContent className='relative flex flex-col items-center justify-center p-3 text-center'>
<button
type='button'
className='absolute inset-0 z-10 h-full w-full cursor-pointer'
@@ -98,13 +98,18 @@ export function AuthSelector({
disabled={disabled}
/>
<div className='justify-center text-center align-middle'>
<h3 className='font-medium text-sm'>
<h3
className={cn(
'font-medium text-xs',
authType === type && 'text-[var(--text-primary)]'
)}
>
{type === 'public' && 'Public Access'}
{type === 'password' && 'Password Protected'}
{type === 'email' && 'Email Access'}
{type === 'sso' && 'SSO Access'}
</h3>
<p className='text-muted-foreground text-xs'>
<p className='text-[11px] text-[var(--text-tertiary)]'>
{type === 'public' && 'Anyone can access your chat'}
{type === 'password' && 'Secure with a single password'}
{type === 'email' && 'Restrict to specific emails'}
@@ -118,13 +123,15 @@ export function AuthSelector({
{/* Auth Settings */}
{authType === 'password' && (
<Card className='rounded-[8px] shadow-none'>
<Card className='rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] shadow-none dark:bg-[var(--surface-9)]'>
<CardContent className='p-4'>
<h3 className='mb-2 font-medium text-sm'>Password Settings</h3>
<h3 className='mb-2 font-medium text-[var(--text-primary)] text-xs'>
Password Settings
</h3>
{isExistingChat && !password && (
<div className='mb-2 flex items-center text-muted-foreground text-xs'>
<div className='mr-2 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-muted-foreground'>
<div className='mb-2 flex items-center text-[11px] text-[var(--text-secondary)]'>
<div className='mr-2 rounded-full bg-[var(--surface-9)] px-2 py-0.5 font-medium text-[var(--text-secondary)] dark:bg-[var(--surface-11)]'>
Password set
</div>
<span>Current password is securely stored</span>
@@ -142,66 +149,46 @@ export function AuthSelector({
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
disabled={disabled}
className='h-10 rounded-[8px] pr-32'
className='pr-28'
required={!isExistingChat}
autoComplete='new-password'
/>
<div className='absolute top-0.5 right-0.5 flex h-9 items-center gap-1 pr-1'>
<div className='-translate-y-1/2 absolute top-1/2 right-1 flex items-center gap-1'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={handleGeneratePassword}
disabled={disabled}
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:bg-muted/50 hover:text-foreground',
'disabled:cursor-not-allowed disabled:opacity-50',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
className='h-6 w-6 p-0'
>
<RefreshCw className='h-3.5 w-3.5 transition-transform duration-200 group-hover:rotate-90' />
<RefreshCw className='h-3.5 w-3.5 transition-transform duration-200 hover:rotate-90' />
<span className='sr-only'>Generate password</span>
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => copyToClipboard(password)}
disabled={!password || disabled}
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:bg-muted/50 hover:text-foreground',
'disabled:cursor-not-allowed disabled:opacity-30',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
className='h-6 w-6 p-0'
>
{copySuccess ? (
<Check className='h-3.5 w-3.5 text-foreground' />
<Check className='h-3.5 w-3.5' />
) : (
<Copy className='h-3.5 w-3.5 ' />
<Copy className='h-3.5 w-3.5' />
)}
<span className='sr-only'>Copy password</span>
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:bg-muted/50 hover:text-foreground',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
className='h-6 w-6 p-0'
>
{showPassword ? (
<EyeOff className='h-3.5 w-3.5 ' />
<EyeOff className='h-3.5 w-3.5' />
) : (
<Eye className='h-3.5 w-3.5 ' />
<Eye className='h-3.5 w-3.5' />
)}
<span className='sr-only'>
{showPassword ? 'Hide password' : 'Show password'}
@@ -210,7 +197,7 @@ export function AuthSelector({
</div>
</div>
<p className='mt-2 text-muted-foreground text-xs'>
<p className='mt-2 text-[11px] text-[var(--text-secondary)]'>
{isExistingChat
? 'Leaving this empty will keep the current password. Enter a new password to change it.'
: 'This password will be required to access your chat.'}
@@ -220,9 +207,9 @@ export function AuthSelector({
)}
{(authType === 'email' || authType === 'sso') && (
<Card className='rounded-[8px] shadow-none'>
<Card className='rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] shadow-none dark:bg-[var(--surface-9)]'>
<CardContent className='p-4'>
<h3 className='mb-2 font-medium text-sm'>
<h3 className='mb-2 font-medium text-[var(--text-primary)] text-xs'>
{authType === 'email' ? 'Email Access Settings' : 'SSO Access Settings'}
</h3>
@@ -232,7 +219,7 @@ export function AuthSelector({
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={disabled}
className='h-10 flex-1 rounded-[8px]'
className='flex-1'
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
@@ -242,9 +229,10 @@ export function AuthSelector({
/>
<Button
type='button'
variant='default'
onClick={handleAddEmail}
disabled={!newEmail.trim() || disabled}
className='h-10 shrink-0 rounded-[8px]'
className='shrink-0 gap-[4px]'
>
<Plus className='h-4 w-4' />
Add
@@ -263,10 +251,9 @@ export function AuthSelector({
<Button
type='button'
variant='ghost'
size='icon'
onClick={() => handleRemoveEmail(email)}
disabled={disabled}
className='h-7 w-7 opacity-70'
className='h-7 w-7 p-0 opacity-70'
>
<Trash className='h-4 w-4' />
</Button>
@@ -277,7 +264,7 @@ export function AuthSelector({
</div>
)}
<p className='mt-2 text-muted-foreground text-xs'>
<p className='mt-2 text-[11px] text-[var(--text-secondary)]'>
{authType === 'email'
? 'Add specific emails or entire domains (@example.com)'
: 'Add specific emails or entire domains (@example.com) that can access via SSO'}
@@ -287,10 +274,12 @@ export function AuthSelector({
)}
{authType === 'public' && (
<Card className='rounded-[8px] shadow-none'>
<Card className='rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] shadow-none dark:bg-[var(--surface-9)]'>
<CardContent className='p-4'>
<h3 className='mb-2 font-medium text-sm'>Public Access Settings</h3>
<p className='text-muted-foreground text-xs'>
<h3 className='mb-2 font-medium text-[var(--text-primary)] text-xs'>
Public Access Settings
</h3>
<p className='text-[11px] text-[var(--text-secondary)]'>
This chat will be publicly accessible to anyone with the link.
</p>
</CardContent>

View File

@@ -47,8 +47,8 @@ export function IdentifierInput({
<Label htmlFor='identifier' className='font-medium text-sm'>
Identifier
</Label>
<div className='relative flex items-center rounded-md ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'>
<div className='flex h-10 items-center whitespace-nowrap rounded-l-md border border-r-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
<div className='relative flex items-stretch'>
<div className='flex items-center whitespace-nowrap rounded-l-[4px] border border-[var(--surface-11)] border-r-0 bg-[var(--surface-6)] px-[8px] py-[6px] font-medium text-[var(--text-secondary)] text-sm dark:bg-[var(--surface-9)]'>
{getDomainPrefix()}
</div>
<div className='relative flex-1'>
@@ -60,9 +60,9 @@ export function IdentifierInput({
required
disabled={disabled}
className={cn(
'rounded-l-none border-l-0 focus-visible:ring-0 focus-visible:ring-offset-0',
'rounded-l-none border-l-0',
isChecking && 'pr-8',
error && 'border-destructive focus-visible:border-destructive'
error && 'border-destructive'
)}
/>
{isChecking && (

View File

@@ -9,7 +9,7 @@ interface DeployStatusProps {
export function DeployStatus({ needsRedeployment }: DeployStatusProps) {
return (
<div className='flex items-center gap-2'>
<span className='font-medium text-muted-foreground text-xs'>Status:</span>
<span className='font-medium text-[var(--text-secondary)] text-xs'>Status:</span>
<div className='flex items-center gap-1.5'>
<div className='relative flex items-center justify-center'>
{needsRedeployment ? (
@@ -28,8 +28,8 @@ export function DeployStatus({ needsRedeployment }: DeployStatusProps) {
className={cn(
'font-medium text-xs',
needsRedeployment
? 'bg-amber-50 text-amber-600 dark:bg-amber-900/20 dark:text-amber-400'
: 'bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400'
? 'text-amber-600 dark:text-amber-400'
: 'text-green-600 dark:text-green-400'
)}
>
{needsRedeployment ? 'Changes Detected' : 'Active'}

View File

@@ -147,14 +147,14 @@ export function ExampleCommand({
{showLabel && <Label className='font-medium text-sm'>Example</Label>}
<div className='flex items-center gap-1'>
<Button
variant={mode === 'sync' ? 'primary' : 'outline'}
variant={mode === 'sync' ? 'active' : 'default'}
onClick={() => setMode('sync')}
className='h-6 min-w-[50px] px-2 py-1 text-xs'
>
Sync
</Button>
<Button
variant={mode === 'stream' ? 'primary' : 'outline'}
variant={mode === 'stream' ? 'active' : 'default'}
onClick={() => setMode('stream')}
className='h-6 min-w-[50px] px-2 py-1 text-xs'
>
@@ -163,7 +163,7 @@ export function ExampleCommand({
{isAsyncEnabled && (
<>
<Button
variant={mode === 'async' ? 'primary' : 'outline'}
variant={mode === 'async' ? 'active' : 'default'}
onClick={() => setMode('async')}
className='h-6 min-w-[50px] px-2 py-1 text-xs'
>

View File

@@ -133,7 +133,7 @@ export function DeploymentInfo({
</Button>
{deploymentInfo.needsRedeployment && (
<Button
variant='outline'
variant='primary'
onClick={onRedeploy}
disabled={isSubmitting}
className='h-8 text-xs'
@@ -172,10 +172,9 @@ export function DeploymentInfo({
<ModalHeader>
<ModalTitle>Undeploy API</ModalTitle>
<ModalDescription>
Are you sure you want to undeploy this workflow? This will remove the API endpoint and
make it unavailable to external users.{' '}
Are you sure you want to undeploy this workflow?{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
This will remove the API endpoint and make it unavailable to external users.{' '}
</span>
</ModalDescription>
</ModalHeader>

View File

@@ -2,18 +2,15 @@
import { useEffect, useRef, useState } from 'react'
import { Loader2, MoreVertical, X } from 'lucide-react'
import { Badge, Button } from '@/components/emcn'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Button as UIButton,
} from '@/components/ui'
Badge,
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
} from '@/components/emcn'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers'
@@ -658,10 +655,7 @@ export function DeployModal({
<div className='flex items-center gap-2'>
<DialogTitle className='font-medium text-lg'>Deploy Workflow</DialogTitle>
{needsRedeployment && versions.length > 0 && versionToActivate === null && (
<Badge
variant='outline'
className='border-purple-500/20 bg-purple-500/10 text-purple-600 dark:text-purple-400'
>
<Badge variant='default'>
{versions.find((v) => v.isActive)?.name ||
`v${versions.find((v) => v.isActive)?.version}`}{' '}
active
@@ -858,38 +852,34 @@ export function DeployModal({
className='px-4 py-2.5'
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu
<Popover
open={openDropdown === v.version}
onOpenChange={(open) =>
setOpenDropdown(open ? v.version : null)
}
>
<DropdownMenuTrigger asChild>
<UIButton
<PopoverTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
disabled={activatingVersion === v.version}
className='h-8 w-8 p-0'
>
<MoreVertical className='h-4 w-4' />
</UIButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
</Button>
</PopoverTrigger>
<PopoverContent align='end'>
<PopoverItem
onClick={() => openVersionPreview(v.version)}
>
{v.isActive ? 'View Active' : 'Inspect'}
</DropdownMenuItem>
<DropdownMenuItem
</PopoverItem>
<PopoverItem
onClick={() => handleStartRename(v.version, v.name)}
>
Rename
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PopoverItem>
</PopoverContent>
</Popover>
</td>
</tr>
))}
@@ -905,22 +895,20 @@ export function DeployModal({
{versions.length}
</span>
<div className='flex gap-2'>
<UIButton
<Button
variant='outline'
size='sm'
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</UIButton>
<UIButton
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage * itemsPerPage >= versions.length}
>
Next
</UIButton>
</Button>
</div>
</div>
)}

View File

@@ -461,7 +461,26 @@ export function MessagesInput({
fieldHandlers.onChange(e)
autoResizeTextarea(fieldId)
}}
onKeyDown={fieldHandlers.onKeyDown}
onKeyDown={(e) => {
if (e.key === 'Tab' && !isPreview && !disabled) {
e.preventDefault()
const direction = e.shiftKey ? -1 : 1
const nextIndex = index + direction
if (nextIndex >= 0 && nextIndex < currentMessages.length) {
const nextFieldId = `message-${nextIndex}`
const nextTextarea = textareaRefs.current[nextFieldId]
if (nextTextarea) {
nextTextarea.focus()
nextTextarea.selectionStart = nextTextarea.value.length
nextTextarea.selectionEnd = nextTextarea.value.length
}
}
return
}
fieldHandlers.onKeyDown(e)
}}
onDrop={fieldHandlers.onDrop}
onDragOver={fieldHandlers.onDragOver}
onScroll={(e) => {

View File

@@ -373,7 +373,7 @@ export function Variables() {
return (
<div
className='fixed z-30 flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)] px-[10px] pt-[2px] pb-[8px]'
className='fixed z-30 flex flex-col overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--surface-1)] px-[10px] pt-[2px] pb-[8px]'
style={{
left: `${actualPosition.x}px`,
top: `${actualPosition.y}px`,
@@ -421,7 +421,7 @@ export function Variables() {
{/* Content */}
<div className='flex flex-1 flex-col overflow-hidden pt-[8px]'>
{workflowVariables.length === 0 ? (
<div className='flex flex-1 items-center justify-center text-[13px] text-[var(--text-tertiary)]'>
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[13px]'>
{STRINGS.emptyState}
</div>
) : (

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import fuzzysort from 'fuzzysort'
import { BookOpen, Layout, RepeatIcon, ScrollText, Search, SplitIcon } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
@@ -98,7 +99,6 @@ export function SearchModal({
const workspaceId = params.workspaceId as string
const brand = useBrandConfig()
// Get all available blocks - only when on workflow page
const blocks = useMemo(() => {
if (!isOnWorkflowPage) return []
@@ -118,7 +118,6 @@ export function SearchModal({
})
)
// Add special blocks (loop and parallel)
const specialBlocks: BlockItem[] = [
{
id: 'loop',
@@ -147,7 +146,6 @@ export function SearchModal({
const allTriggers = getTriggersForSidebar()
const priorityOrder = ['Start', 'Schedule', 'Webhook']
// Sort triggers with priority order matching toolbar
const sortedTriggers = allTriggers.sort((a, b) => {
const aIndex = priorityOrder.indexOf(a.name)
const bIndex = priorityOrder.indexOf(b.name)
@@ -173,7 +171,6 @@ export function SearchModal({
)
}, [isOnWorkflowPage])
// Get all available tools - only when on workflow page
const tools = useMemo(() => {
if (!isOnWorkflowPage) return []
@@ -192,7 +189,6 @@ export function SearchModal({
)
}, [isOnWorkflowPage])
// Define pages
const pages = useMemo(
(): PageItem[] => [
{
@@ -218,7 +214,6 @@ export function SearchModal({
[workspaceId, brand.documentationUrl]
)
// Define docs
const docs = useMemo((): DocItem[] => {
const allBlocks = getAllBlocks()
const docsItems: DocItem[] = []
@@ -238,11 +233,9 @@ export function SearchModal({
return docsItems
}, [])
// Combine all items into a single flattened list
const allItems = useMemo((): SearchItem[] => {
const items: SearchItem[] = []
// Add workspaces
workspaces.forEach((workspace) => {
items.push({
id: workspace.id,
@@ -253,7 +246,6 @@ export function SearchModal({
})
})
// Add workflows
workflows.forEach((workflow) => {
items.push({
id: workflow.id,
@@ -265,7 +257,6 @@ export function SearchModal({
})
})
// Add pages
pages.forEach((page) => {
items.push({
id: page.id,
@@ -277,7 +268,6 @@ export function SearchModal({
})
})
// Add blocks
blocks.forEach((block) => {
items.push({
id: block.id,
@@ -290,7 +280,6 @@ export function SearchModal({
})
})
// Add triggers
triggers.forEach((trigger) => {
items.push({
id: trigger.id,
@@ -304,7 +293,6 @@ export function SearchModal({
})
})
// Add tools
tools.forEach((tool) => {
items.push({
id: tool.id,
@@ -317,7 +305,6 @@ export function SearchModal({
})
})
// Add docs
docs.forEach((doc) => {
items.push({
id: doc.id,
@@ -336,7 +323,6 @@ export function SearchModal({
[]
)
// Filter items based on search query and enforce section ordering
const filteredItems = useMemo(() => {
const orderMap = sectionOrder.reduce<Record<SearchItem['type'], number>>(
(acc, type, index) => {
@@ -346,27 +332,48 @@ export function SearchModal({
{} as Record<SearchItem['type'], number>
)
const baseItems = !searchQuery.trim()
? allItems
: allItems.filter(
(item) =>
item.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
return [...baseItems].sort((a, b) => {
if (!searchQuery.trim()) {
return [...allItems].sort((a, b) => {
const aOrder = orderMap[a.type] ?? Number.MAX_SAFE_INTEGER
const bOrder = orderMap[b.type] ?? Number.MAX_SAFE_INTEGER
return aOrder - bOrder
})
}
const results = fuzzysort.go(searchQuery, allItems, {
keys: ['name', 'description'],
limit: 100,
threshold: -1000,
all: true,
scoreFn: (a) => {
const nameScore = a[0] ? a[0].score : Number.NEGATIVE_INFINITY
const descScore = a[1] ? a[1].score : Number.NEGATIVE_INFINITY
return Math.max(nameScore * 2, descScore)
},
})
return results
.map((result) => ({
item: result.obj,
score: result.score,
}))
.sort((a, b) => {
if (Math.abs(a.score - b.score) > 100) {
return b.score - a.score
}
const aOrder = orderMap[a.item.type] ?? Number.MAX_SAFE_INTEGER
const bOrder = orderMap[b.item.type] ?? Number.MAX_SAFE_INTEGER
return aOrder - bOrder
})
.map((result) => result.item)
}, [allItems, searchQuery, sectionOrder])
// Reset selected index when filtered items change
useEffect(() => {
setSelectedIndex(0)
}, [filteredItems])
// Clear search when modal closes
useEffect(() => {
if (!open) {
setSearchQuery('')
@@ -374,7 +381,6 @@ export function SearchModal({
}
}, [open])
// Handle item selection
const handleItemClick = useCallback(
(item: SearchItem) => {
switch (item.type) {
@@ -411,7 +417,6 @@ export function SearchModal({
[router, onOpenChange]
)
// Handle keyboard navigation
useEffect(() => {
if (!open) return
@@ -442,7 +447,6 @@ export function SearchModal({
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, selectedIndex, filteredItems, handleItemClick, onOpenChange])
// Scroll selected item into view
useEffect(() => {
if (open && selectedIndex >= 0) {
const element = document.querySelector(`[data-search-item-index="${selectedIndex}"]`)
@@ -455,7 +459,6 @@ export function SearchModal({
}
}, [selectedIndex, open])
// Group items by type for sectioned display
const groupedItems = useMemo(() => {
const groups: Record<string, SearchItem[]> = {
workspace: [],
@@ -476,7 +479,6 @@ export function SearchModal({
return groups
}, [filteredItems])
// Section titles mapping
const sectionTitles: Record<string, string> = {
workspace: 'Workspaces',
workflow: 'Workflows',

View File

@@ -0,0 +1,300 @@
import { CalendlyIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { ToolResponse } from '@/tools/types'
import { getTrigger } from '@/triggers'
export const CalendlyBlock: BlockConfig<ToolResponse> = {
type: 'calendly',
name: 'Calendly',
description: 'Manage Calendly scheduling and events',
authMode: AuthMode.ApiKey,
triggerAllowed: true,
longDescription:
'Integrate Calendly into your workflow. Manage event types, scheduled events, invitees, and webhooks. Can also trigger workflows based on Calendly webhook events (invitee scheduled, invitee canceled, routing form submitted). Requires Personal Access Token.',
docsLink: 'https://docs.sim.ai/tools/calendly',
category: 'tools',
bgColor: '#FFFFFF',
icon: CalendlyIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Get Current User', id: 'calendly_get_current_user' },
{ label: 'List Event Types', id: 'calendly_list_event_types' },
{ label: 'Get Event Type', id: 'calendly_get_event_type' },
{ label: 'List Scheduled Events', id: 'calendly_list_scheduled_events' },
{ label: 'Get Scheduled Event', id: 'calendly_get_scheduled_event' },
{ label: 'List Event Invitees', id: 'calendly_list_event_invitees' },
{ label: 'Cancel Event', id: 'calendly_cancel_event' },
],
value: () => 'calendly_list_scheduled_events',
},
{
id: 'apiKey',
title: 'Personal Access Token',
type: 'short-input',
placeholder: 'Enter your Calendly personal access token',
password: true,
required: true,
},
// Get Event Type fields
{
id: 'eventTypeUuid',
title: 'Event Type UUID',
type: 'short-input',
placeholder: 'Enter event type UUID or URI',
required: true,
condition: { field: 'operation', value: 'calendly_get_event_type' },
},
// List Event Types fields
{
id: 'user',
title: 'User URI',
type: 'short-input',
placeholder: 'Filter by user URI',
condition: {
field: 'operation',
value: ['calendly_list_event_types', 'calendly_list_scheduled_events'],
},
},
{
id: 'organization',
title: 'Organization URI',
type: 'short-input',
placeholder: 'Filter by organization URI (optional)',
condition: {
field: 'operation',
value: ['calendly_list_event_types', 'calendly_list_scheduled_events'],
},
},
{
id: 'active',
title: 'Active Only',
type: 'switch',
description:
'When enabled, shows only active event types. When disabled, shows all event types.',
condition: { field: 'operation', value: 'calendly_list_event_types' },
},
// List Scheduled Events fields
{
id: 'invitee_email',
title: 'Invitee Email',
type: 'short-input',
placeholder: 'Filter by invitee email',
condition: { field: 'operation', value: 'calendly_list_scheduled_events' },
},
{
id: 'min_start_time',
title: 'Min Start Time',
type: 'short-input',
placeholder: 'ISO 8601 format (e.g., 2024-01-01T00:00:00Z)',
condition: { field: 'operation', value: 'calendly_list_scheduled_events' },
},
{
id: 'max_start_time',
title: 'Max Start Time',
type: 'short-input',
placeholder: 'ISO 8601 format (e.g., 2024-12-31T23:59:59Z)',
condition: { field: 'operation', value: 'calendly_list_scheduled_events' },
},
{
id: 'status',
title: 'Status',
type: 'dropdown',
options: [
{ label: 'All', id: '' },
{ label: 'Active', id: 'active' },
{ label: 'Canceled', id: 'canceled' },
],
condition: {
field: 'operation',
value: ['calendly_list_scheduled_events', 'calendly_list_event_invitees'],
},
},
// Get Scheduled Event / List Invitees / Cancel Event fields
{
id: 'eventUuid',
title: 'Event UUID',
type: 'short-input',
placeholder: 'Enter scheduled event UUID or URI',
required: true,
condition: {
field: 'operation',
value: [
'calendly_get_scheduled_event',
'calendly_list_event_invitees',
'calendly_cancel_event',
],
},
},
// Cancel Event fields
{
id: 'reason',
title: 'Cancellation Reason',
type: 'long-input',
placeholder: 'Reason for cancellation (optional)',
condition: { field: 'operation', value: 'calendly_cancel_event' },
},
// List Event Invitees fields
{
id: 'email',
title: 'Email',
type: 'short-input',
placeholder: 'Filter by invitee email',
condition: { field: 'operation', value: 'calendly_list_event_invitees' },
},
// Pagination fields
{
id: 'count',
title: 'Results Per Page',
type: 'short-input',
placeholder: 'Number of results (default: 20, max: 100)',
condition: {
field: 'operation',
value: [
'calendly_list_event_types',
'calendly_list_scheduled_events',
'calendly_list_event_invitees',
],
},
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination',
condition: {
field: 'operation',
value: [
'calendly_list_event_types',
'calendly_list_scheduled_events',
'calendly_list_event_invitees',
],
},
},
{
id: 'sort',
title: 'Sort Order',
type: 'short-input',
placeholder: 'e.g., "name:asc", "start_time:desc"',
condition: {
field: 'operation',
value: [
'calendly_list_event_types',
'calendly_list_scheduled_events',
'calendly_list_event_invitees',
],
},
},
// Trigger SubBlocks
...getTrigger('calendly_invitee_created').subBlocks,
...getTrigger('calendly_invitee_canceled').subBlocks,
...getTrigger('calendly_routing_form_submitted').subBlocks,
...getTrigger('calendly_webhook').subBlocks,
],
tools: {
access: [
'calendly_get_current_user',
'calendly_list_event_types',
'calendly_get_event_type',
'calendly_list_scheduled_events',
'calendly_get_scheduled_event',
'calendly_list_event_invitees',
'calendly_cancel_event',
],
config: {
tool: (params) => {
return params.operation || 'calendly_list_scheduled_events'
},
params: (params) => {
const { operation, events, ...rest } = params
let parsedEvents: any | undefined
try {
if (events) parsedEvents = JSON.parse(events)
} catch (error: any) {
throw new Error(`Invalid JSON input for events: ${error.message}`)
}
return {
...rest,
...(parsedEvents && { events: parsedEvents }),
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'Personal access token' },
// Event Type params
eventTypeUuid: { type: 'string', description: 'Event type UUID' },
user: { type: 'string', description: 'User URI filter' },
organization: { type: 'string', description: 'Organization URI' },
active: { type: 'boolean', description: 'Filter by active status' },
// Scheduled Event params
eventUuid: { type: 'string', description: 'Scheduled event UUID' },
invitee_email: { type: 'string', description: 'Filter by invitee email' },
min_start_time: { type: 'string', description: 'Minimum start time (ISO 8601)' },
max_start_time: { type: 'string', description: 'Maximum start time (ISO 8601)' },
status: { type: 'string', description: 'Status filter (active or canceled)' },
// Cancel Event params
reason: { type: 'string', description: 'Cancellation reason' },
// Invitees params
email: { type: 'string', description: 'Filter by email' },
// Pagination params
count: { type: 'number', description: 'Results per page' },
pageToken: { type: 'string', description: 'Pagination token' },
sort: { type: 'string', description: 'Sort order' },
// Webhook params
webhookUuid: { type: 'string', description: 'Webhook UUID' },
url: { type: 'string', description: 'Webhook callback URL' },
events: { type: 'json', description: 'Array of event types' },
scope: { type: 'string', description: 'Webhook scope' },
signing_key: { type: 'string', description: 'Webhook signing key' },
},
outputs: {
// Common outputs
success: { type: 'boolean', description: 'Whether operation succeeded' },
// User outputs
resource: { type: 'json', description: 'Resource data (user, event type, event, etc.)' },
// List outputs
collection: { type: 'json', description: 'Array of items' },
pagination: { type: 'json', description: 'Pagination information' },
// Event details
uri: { type: 'string', description: 'Resource URI' },
name: { type: 'string', description: 'Resource name' },
email: { type: 'string', description: 'Email address' },
status: { type: 'string', description: 'Status' },
start_time: { type: 'string', description: 'Event start time (ISO 8601)' },
end_time: { type: 'string', description: 'Event end time (ISO 8601)' },
location: { type: 'json', description: 'Event location details' },
scheduling_url: { type: 'string', description: 'Scheduling page URL' },
// Webhook outputs
callback_url: { type: 'string', description: 'Webhook URL' },
signing_key: { type: 'string', description: 'Webhook signing key' },
// Delete outputs
deleted: { type: 'boolean', description: 'Whether deletion succeeded' },
message: { type: 'string', description: 'Status message' },
// Trigger outputs
event: { type: 'string', description: 'Webhook event type' },
created_at: { type: 'string', description: 'Webhook event creation timestamp' },
created_by: {
type: 'string',
description: 'URI of the Calendly user who created this webhook',
},
payload: { type: 'json', description: 'Complete webhook payload data' },
},
triggers: {
enabled: true,
available: [
'calendly_invitee_created',
'calendly_invitee_canceled',
'calendly_routing_form_submitted',
'calendly_webhook',
],
},
}

View File

@@ -13,7 +13,7 @@ export const McpBlock: BlockConfig<McpResponse> = {
description: 'Execute tools from Model Context Protocol (MCP) servers',
longDescription:
'Integrate MCP into the workflow. Can execute tools from MCP servers. Requires MCP servers in workspace settings.',
docsLink: 'https://docs.sim.ai/tools/mcp',
docsLink: 'https://docs.sim.ai/mcp',
category: 'tools',
bgColor: '#181C1E',
icon: ServerIcon,

View File

@@ -31,7 +31,7 @@ export const MemoryBlock: BlockConfig = {
value: () => 'add',
},
{
id: 'conversationId',
id: 'id',
title: 'Conversation ID',
type: 'short-input',
placeholder: 'Enter conversation ID (e.g., user-123)',
@@ -53,7 +53,7 @@ export const MemoryBlock: BlockConfig = {
required: false,
},
{
id: 'conversationId',
id: 'id',
title: 'Conversation ID',
type: 'short-input',
placeholder: 'Enter conversation ID (e.g., user-123)',
@@ -86,7 +86,7 @@ export const MemoryBlock: BlockConfig = {
required: false,
},
{
id: 'conversationId',
id: 'id',
title: 'Conversation ID',
type: 'short-input',
placeholder: 'Enter conversation ID (e.g., user-123)',
@@ -171,8 +171,10 @@ export const MemoryBlock: BlockConfig = {
errors.push('Operation is required')
}
const conversationId = params.id || params.conversationId
if (params.operation === 'add') {
if (!params.conversationId) {
if (!conversationId) {
errors.push('Conversation ID is required for add operation')
}
if (!params.role) {
@@ -184,9 +186,9 @@ export const MemoryBlock: BlockConfig = {
}
if (params.operation === 'get' || params.operation === 'delete') {
if (!params.conversationId && !params.blockId && !params.blockName) {
if (!conversationId && !params.blockId && !params.blockName) {
errors.push(
`At least one of conversationId, blockId, or blockName is required for ${params.operation} operation`
`At least one of ID, blockId, or blockName is required for ${params.operation} operation`
)
}
}
@@ -200,7 +202,7 @@ export const MemoryBlock: BlockConfig = {
if (params.operation === 'add') {
const result: Record<string, any> = {
...baseResult,
conversationId: params.conversationId,
conversationId: conversationId,
role: params.role,
content: params.content,
}
@@ -213,7 +215,7 @@ export const MemoryBlock: BlockConfig = {
if (params.operation === 'get') {
const result: Record<string, any> = { ...baseResult }
if (params.conversationId) result.conversationId = params.conversationId
if (conversationId) result.conversationId = conversationId
if (params.blockId) result.blockId = params.blockId
if (params.blockName) result.blockName = params.blockName
return result
@@ -221,7 +223,7 @@ export const MemoryBlock: BlockConfig = {
if (params.operation === 'delete') {
const result: Record<string, any> = { ...baseResult }
if (params.conversationId) result.conversationId = params.conversationId
if (conversationId) result.conversationId = conversationId
if (params.blockId) result.blockId = params.blockId
if (params.blockName) result.blockName = params.blockName
return result

View File

@@ -0,0 +1,696 @@
import { Neo4jIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import type { Neo4jResponse } from '@/tools/neo4j/types'
export const Neo4jBlock: BlockConfig<Neo4jResponse> = {
type: 'neo4j',
name: 'Neo4j',
description: 'Connect to Neo4j graph database',
longDescription:
'Integrate Neo4j graph database into the workflow. Can query, create, merge, update, and delete nodes and relationships.',
docsLink: 'https://docs.sim.ai/tools/neo4j',
category: 'tools',
bgColor: '#FFFFFF',
icon: Neo4jIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Query (MATCH)', id: 'query' },
{ label: 'Create Nodes/Relationships', id: 'create' },
{ label: 'Merge (Find or Create)', id: 'merge' },
{ label: 'Update Properties (SET)', id: 'update' },
{ label: 'Delete Nodes/Relationships', id: 'delete' },
{ label: 'Execute Cypher', id: 'execute' },
],
value: () => 'query',
},
{
id: 'host',
title: 'Host',
type: 'short-input',
placeholder: 'localhost or your.neo4j.host',
required: true,
password: true,
},
{
id: 'port',
title: 'Port',
type: 'short-input',
placeholder: '7687',
value: () => '7687',
required: true,
},
{
id: 'database',
title: 'Database Name',
type: 'short-input',
placeholder: 'neo4j',
value: () => 'neo4j',
required: true,
},
{
id: 'username',
title: 'Username',
type: 'short-input',
placeholder: 'neo4j',
value: () => 'neo4j',
required: true,
},
{
id: 'password',
title: 'Password',
type: 'short-input',
password: true,
placeholder: 'Your database password',
required: true,
},
{
id: 'encryption',
title: 'Encryption',
type: 'dropdown',
options: [
{ label: 'Disabled', id: 'disabled' },
{ label: 'Enabled (TLS/SSL)', id: 'enabled' },
],
value: () => 'disabled',
},
{
id: 'cypherQuery',
title: 'Cypher Query',
type: 'code',
placeholder: 'MATCH (n:Person) WHERE n.age > 21 RETURN n LIMIT 10',
required: true,
condition: { field: 'operation', value: 'query' },
wandConfig: {
enabled: true,
maintainHistory: true,
prompt: `You are an expert Neo4j and Cypher developer. Generate Cypher queries based on the user's request.
### CONTEXT
{context}
### CRITICAL INSTRUCTION
Return ONLY the Cypher query. Do not include any explanations, markdown formatting, comments, or additional text. Just the raw Cypher query.
### QUERY GUIDELINES
1. **Pattern Matching**: Use MATCH to find patterns in the graph
2. **Filtering**: Use WHERE clauses for conditions
3. **Return**: Specify what to return with RETURN
4. **Performance**: Use indexes when possible
5. **Limit Results**: ALWAYS include LIMIT to prevent large result sets
### IMPORTANT: LIMIT Best Practices
- Always include LIMIT in your queries to prevent performance issues
- Use parameterized LIMIT for flexibility: LIMIT $limit
- Place LIMIT at the end after ORDER BY and SKIP clauses
- Example: MATCH (n:Person) RETURN n ORDER BY n.name LIMIT $limit
### CYPHER QUERY PATTERNS
**Basic Node Match with LIMIT**:
MATCH (n:Person) RETURN n LIMIT 25
**Match with Parameterized LIMIT (Recommended)**:
MATCH (n:Person) RETURN n LIMIT $limit
**Match with Properties**:
MATCH (n:Person {name: "Alice"}) RETURN n
**Match with Parameters (Recommended)**:
MATCH (n:Person {name: $name}) RETURN n LIMIT 100
**Match with WHERE and Parameters**:
MATCH (n:Person) WHERE n.age > $minAge RETURN n.name, n.age LIMIT $limit
**Match Relationship**:
MATCH (p:Person)-[:KNOWS]->(friend:Person) RETURN p.name, friend.name
**Match with Relationship Properties**:
MATCH (p:Person)-[r:RATED {rating: 5}]->(m:Movie) RETURN p.name, m.title
**Pattern with Multiple Nodes**:
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)<-[:DIRECTED]-(d:Person) RETURN p.name, m.title, d.name
**Variable Length Paths**:
MATCH (p1:Person)-[:KNOWS*1..3]-(p2:Person) WHERE p1.name = $name RETURN p2.name
**Shortest Path**:
MATCH path = shortestPath((p1:Person)-[:KNOWS*]-(p2:Person)) WHERE p1.name = $name1 AND p2.name = $name2 RETURN path
**Complex WHERE with Parameters**:
MATCH (p:Person) WHERE p.age > $minAge AND p.age < $maxAge AND p.country = $country RETURN p
### EXAMPLES
**Find all nodes**: MATCH (n:Person) RETURN n LIMIT 10
**Find by property**: MATCH (n:Person) WHERE n.name = "Alice" RETURN n
**Find with parameters**: MATCH (n:Person) WHERE n.name = $name AND n.age > $minAge RETURN n
**Find relationships**: MATCH (p:Person)-[r:KNOWS]->(f:Person) RETURN p.name, type(r), f.name
**Find with multiple labels**: MATCH (n:Person:Employee) RETURN n
**Aggregate data**: MATCH (p:Person) RETURN p.country, count(p) as personCount ORDER BY personCount DESC
**Range query with params**: MATCH (p:Product) WHERE p.price >= $minPrice AND p.price <= $maxPrice RETURN p
### NOTE
Use the Parameters field for dynamic values to improve security and query performance.
Return ONLY the Cypher query - no explanations.`,
placeholder: 'Describe what you want to query...',
generationType: 'neo4j-cypher',
},
},
{
id: 'cypherQuery',
title: 'Cypher CREATE Statement',
type: 'code',
placeholder: 'CREATE (n:Person {name: "Alice", age: 30})',
required: true,
condition: { field: 'operation', value: 'create' },
wandConfig: {
enabled: true,
maintainHistory: true,
prompt: `You are an expert Neo4j developer. Generate Cypher CREATE statements to add new nodes and relationships.
### CONTEXT
{context}
### CRITICAL INSTRUCTION
Return ONLY the Cypher CREATE statement. No explanations, no markdown, just the raw Cypher query.
### ⚠️ DUPLICATE WARNING
CREATE always creates new nodes/relationships, even if identical ones exist.
- Use MERGE operation if you want to avoid duplicates (find-or-create behavior)
- Use CREATE only when you want to guarantee new entities
### CREATE PATTERNS
**Create Single Node**:
CREATE (n:Person {name: "Alice", age: 30, email: "alice@example.com"})
**Create with Parameters (Recommended)**:
CREATE (n:Person {name: $name, age: $age, email: $email}) RETURN n
**Create Multiple Nodes**:
CREATE (n1:Person {name: "Alice"}), (n2:Person {name: "Bob"})
**Create Node with Relationship**:
CREATE (p:Person {name: "Alice"})-[:KNOWS {since: 2020}]->(f:Person {name: "Bob"})
**Create Relationship Between Existing Nodes**:
MATCH (a:Person {name: "Alice"}), (b:Person {name: "Bob"})
CREATE (a)-[:KNOWS {since: 2024}]->(b)
**Create with Parameters and Return**:
CREATE (n:Person {name: $name, age: $age}) RETURN n
**Batch Create with Parameters**:
UNWIND $users AS user
CREATE (n:Person {name: user.name, email: user.email})
### EXAMPLES
Create person: CREATE (n:Person {name: "Alice", age: 30})
Create with params: CREATE (n:Person {name: $name, age: $age}) RETURN n
Create with relationship: CREATE (p:Person {name: "Alice"})-[:WORKS_AT]->(c:Company {name: "Acme"})
Create multiple: CREATE (a:Person {name: "Alice"}), (b:Person {name: "Bob"}), (a)-[:KNOWS]->(b)
Batch create: UNWIND $items AS item CREATE (n:Product {name: item.name, price: item.price})
### NOTE
Use the Parameters field with CREATE for dynamic values and security.
Return ONLY the Cypher CREATE statement.`,
placeholder: 'Describe what you want to create...',
generationType: 'neo4j-cypher',
},
},
{
id: 'cypherQuery',
title: 'Cypher MERGE Statement',
type: 'code',
placeholder:
'MERGE (n:Person {email: "alice@example.com"}) ON CREATE SET n.created = timestamp() RETURN n',
required: true,
condition: { field: 'operation', value: 'merge' },
wandConfig: {
enabled: true,
maintainHistory: true,
prompt: `You are an expert Neo4j developer. Generate Cypher MERGE statements for find-or-create operations.
### CONTEXT
{context}
### CRITICAL INSTRUCTION
Return ONLY the Cypher MERGE statement. No explanations, no markdown, just the raw Cypher query.
### MERGE PATTERNS
**Basic Merge**:
MERGE (n:Person {email: "alice@example.com"})
**Merge with ON CREATE**:
MERGE (n:Person {email: "alice@example.com"})
ON CREATE SET n.created = timestamp(), n.name = "Alice"
**Merge with ON MATCH**:
MERGE (n:Person {email: "alice@example.com"})
ON MATCH SET n.lastSeen = timestamp()
**Merge with Both**:
MERGE (n:Person {email: "alice@example.com"})
ON CREATE SET n.created = timestamp(), n.name = "Alice"
ON MATCH SET n.lastSeen = timestamp()
**Merge Relationship**:
MATCH (p:Person {name: "Alice"}), (c:Company {name: "Acme"})
MERGE (p)-[:WORKS_AT {since: 2024}]->(c)
### EXAMPLES
Merge person: MERGE (n:Person {email: "alice@example.com"}) ON CREATE SET n.created = timestamp()
Merge relationship: MERGE (p:Person {name: "Alice"})-[:KNOWS]->(f:Person {name: "Bob"})
Return ONLY the Cypher MERGE statement.`,
placeholder: 'Describe what you want to merge...',
generationType: 'neo4j-cypher',
},
},
{
id: 'cypherQuery',
title: 'Cypher UPDATE Statement',
type: 'code',
placeholder: 'MATCH (n:Person {name: "Alice"}) SET n.age = 31, n.updated = timestamp()',
required: true,
condition: { field: 'operation', value: 'update' },
wandConfig: {
enabled: true,
maintainHistory: true,
prompt: `You are an expert Neo4j developer. Generate Cypher UPDATE statements using MATCH and SET.
### CONTEXT
{context}
### CRITICAL INSTRUCTION
Return ONLY the Cypher statement with MATCH and SET. No explanations, no markdown, just the raw Cypher query.
### UPDATE PATTERNS
**Update Single Property**:
MATCH (n:Person {name: "Alice"}) SET n.age = 31
**Update Multiple Properties**:
MATCH (n:Person {name: "Alice"}) SET n.age = 31, n.city = "NYC", n.updated = timestamp()
**Update with WHERE**:
MATCH (n:Person) WHERE n.age > 30 SET n.category = "senior"
**Update with Parameters**:
MATCH (n:Person {name: $name}) SET n.age = $age, n.city = $city
**Merge Properties (Safe)**:
MATCH (n:Person {name: "Alice"}) SET n += {age: 31, email: "alice@example.com"}
**⚠️ DANGEROUS - Replace All Properties (removes unlisted properties)**:
MATCH (n:Person {name: "Alice"}) SET n = {name: "Alice", age: 31}
Note: This removes ALL properties not specified. Use SET n += {...} instead to merge properties safely.
**Add Property**:
MATCH (n:Person {name: "Alice"}) SET n.verified = true
**Remove Property**:
MATCH (n:Person {name: "Alice"}) REMOVE n.temporaryField
**Add Label**:
MATCH (n:Person {name: "Alice"}) SET n:Employee
**Increment Counter**:
MATCH (n:Person {name: "Alice"}) SET n.loginCount = n.loginCount + 1
### EXAMPLES
Update age: MATCH (n:Person {name: "Alice"}) SET n.age = 31
Update multiple: MATCH (n:Person) WHERE n.city = "NYC" SET n.verified = true, n.updated = timestamp()
With parameters: MATCH (n:Person) WHERE n.email = $email SET n.lastLogin = timestamp()
Merge properties safely: MATCH (n:Person {id: $userId}) SET n += {status: "active", verified: true}
### SAFETY NOTE
Use SET n += {...} to merge properties safely. Avoid SET n = {...} unless you explicitly want to replace ALL properties.
Return ONLY the Cypher update statement.`,
placeholder: 'Describe what you want to update...',
generationType: 'neo4j-cypher',
},
},
{
id: 'cypherQuery',
title: 'Cypher DELETE Statement',
type: 'code',
placeholder: 'MATCH (n:Person {name: "Alice"}) DETACH DELETE n',
required: true,
condition: { field: 'operation', value: 'delete' },
wandConfig: {
enabled: true,
maintainHistory: true,
prompt: `You are an expert Neo4j developer. Generate Cypher DELETE statements to remove nodes and relationships.
### CONTEXT
{context}
### CRITICAL INSTRUCTION
Return ONLY the Cypher DELETE statement. No explanations, no markdown, just the raw Cypher query.
### ⚠️ DELETION WARNING ⚠️
DELETIONS ARE PERMANENT! Be extremely careful and specific with your criteria.
### DELETE PATTERNS
**Delete Node (must have no relationships)**:
MATCH (n:Person {name: "Alice"}) DELETE n
**DETACH DELETE (removes relationships first)**:
MATCH (n:Person {name: "Alice"}) DETACH DELETE n
**Delete Relationship Only**:
MATCH (p:Person {name: "Alice"})-[r:KNOWS]->(f:Person) DELETE r
**Delete with WHERE**:
MATCH (n:Person) WHERE n.status = "inactive" DETACH DELETE n
**Delete Multiple Nodes**:
MATCH (n:TempNode) WHERE n.created < timestamp() - 86400000 DETACH DELETE n
### SAFETY
- Always use DETACH DELETE for nodes with relationships
- Use specific WHERE clauses to target exact nodes
- Test with MATCH first to see what will be deleted
- Prefer unique identifiers when deleting
### EXAMPLES
Delete person: MATCH (n:Person {email: "alice@example.com"}) DETACH DELETE n
Delete relationship: MATCH (p:Person)-[r:KNOWS]->(f:Person) WHERE p.name = "Alice" DELETE r
Delete old data: MATCH (n:TempData) WHERE n.created < timestamp() - 2592000000 DETACH DELETE n
Return ONLY the Cypher DELETE statement.`,
placeholder: 'Describe what you want to delete...',
generationType: 'neo4j-cypher',
},
},
{
id: 'cypherQuery',
title: 'Cypher Query',
type: 'code',
placeholder: 'MATCH (n:Person) RETURN n LIMIT 10',
required: true,
condition: { field: 'operation', value: 'execute' },
wandConfig: {
enabled: true,
maintainHistory: true,
prompt: `You are an expert Neo4j developer. Generate any Cypher query based on the user's request.
### CONTEXT
{context}
### CRITICAL INSTRUCTION
Return ONLY the Cypher query. No explanations, no markdown, just the raw Cypher query.
### ADVANCED PATTERNS
**Aggregation**:
MATCH (p:Person) RETURN p.country, count(p) as total ORDER BY total DESC
**Aggregation with COLLECT**:
MATCH (p:Person)-[:WORKS_AT]->(c:Company) RETURN c.name, collect(p.name) as employees
**Complex Relationships**:
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)<-[:DIRECTED]-(d:Person) RETURN p.name, m.title, d.name
**WITH Clause for Chaining**:
MATCH (p:Person) WITH p ORDER BY p.age DESC LIMIT 10 MATCH (p)-[:KNOWS]->(friend) RETURN p.name, collect(friend.name) as friends
**Conditional Logic (CASE)**:
MATCH (p:Person) RETURN p.name, CASE WHEN p.age < 18 THEN 'minor' WHEN p.age < 65 THEN 'adult' ELSE 'senior' END as ageGroup
**Subqueries with EXISTS**:
MATCH (p:Person) WHERE EXISTS { MATCH (p)-[:KNOWS]->(:Person)-[:KNOWS]->(:Person) } RETURN p
**Subqueries with CALL**:
MATCH (p:Person) CALL { WITH p MATCH (p)-[:KNOWS]->(friend) RETURN count(friend) as friendCount } RETURN p.name, friendCount
**UNION (Combine Results)**:
MATCH (p:Person) WHERE p.age > 30 RETURN p.name, p.age UNION MATCH (p:Person) WHERE p.country = "USA" RETURN p.name, p.age
**FOREACH for Batch Updates**:
MATCH (p:Person) WHERE p.status = 'pending' FOREACH (x IN CASE WHEN p.age > 18 THEN [1] ELSE [] END | SET p.verified = true)
**Path Patterns**:
MATCH path = (p1:Person)-[:KNOWS*1..3]-(p2:Person) WHERE p1.name = "Alice" RETURN path, length(path)
**Transaction Batching** (5.x+):
CALL { MATCH (p:Person) WHERE p.status = 'inactive' DETACH DELETE p } IN TRANSACTIONS OF 100 ROWS
**Optional Match**:
MATCH (p:Person) OPTIONAL MATCH (p)-[:MANAGES]->(e:Person) RETURN p.name, collect(e.name) as employees
**Map Projections**:
MATCH (p:Person) RETURN p { .name, .age, friendCount: size((p)-[:KNOWS]->()) }
**Parameters in Advanced Query**:
MATCH (p:Person) WHERE p.age > $minAge WITH p ORDER BY p.age DESC LIMIT $limit MATCH (p)-[:KNOWS]->(friend) RETURN p.name, collect(friend.name) as friends
**Pattern Comprehension**:
MATCH (p:Person) RETURN p.name, [(p)-[:KNOWS]->(friend) WHERE friend.age > 25 | friend.name] as adultFriends
Return ONLY the Cypher query.`,
placeholder: 'Describe your query...',
generationType: 'neo4j-cypher',
},
},
{
id: 'parameters',
title: 'Parameters',
type: 'code',
placeholder: '{"name": "Alice", "minAge": 21}',
wandConfig: {
enabled: true,
maintainHistory: true,
prompt: `Generate JSON parameters for Cypher queries. Parameters are essential for security, performance, and reusability.
### CONTEXT
{context}
### CRITICAL INSTRUCTION
Return ONLY a valid JSON object with parameter values. No explanations, no markdown, just the raw JSON object.
### WHY USE PARAMETERS?
**Security Benefits:**
- Prevents Cypher injection attacks
- Safely handles user input
- Separates data from query logic
- No need to escape special characters
**Performance Benefits:**
- Neo4j caches query execution plans
- Same query structure with different parameters reuses cached plan
- Significantly faster for repeated queries
**Code Quality:**
- Cleaner, more readable queries
- Easier to test and maintain
- Reusable query templates
### PARAMETER SYNTAX IN QUERIES
In Cypher queries, parameters are prefixed with $ and referenced by name:
MATCH (n:Person {name: $name}) WHERE n.age > $minAge RETURN n
MATCH (p)-[r:KNOWS {since: $year}]->(f) RETURN p, f
CREATE (n:Person {name: $name, email: $email, age: $age})
MERGE (n:User {id: $userId}) ON CREATE SET n.created = $timestamp
### DATA TYPES
**Strings**:
{"name": "Alice", "email": "alice@example.com", "status": "active"}
**Numbers** (integers and floats):
{"age": 30, "score": 95.5, "count": 1000, "rating": 4.8}
**Booleans**:
{"isActive": true, "verified": false, "premium": true}
**Arrays**:
{"tags": ["neo4j", "database", "graph"], "statuses": ["active", "pending"], "scores": [85, 90, 95]}
**Null**:
{"middleName": null, "company": null}
**Mixed Types**:
{"name": "Alice", "age": 30, "active": true, "tags": ["user", "premium"], "balance": 150.50}
### QUERY + PARAMETERS EXAMPLES
**Example 1: Simple Property Match**
Query: MATCH (n:Person {email: $email}) RETURN n
Parameters: {"email": "alice@example.com"}
**Example 2: Range Filter**
Query: MATCH (n:Person) WHERE n.age >= $minAge AND n.age <= $maxAge RETURN n
Parameters: {"minAge": 21, "maxAge": 65}
**Example 3: Array Membership**
Query: MATCH (n:Person) WHERE n.status IN $statuses RETURN n
Parameters: {"statuses": ["active", "pending", "verified"]}
**Example 4: Create with Parameters**
Query: CREATE (n:Person {name: $name, email: $email, age: $age, created: $timestamp})
Parameters: {"name": "Bob", "email": "bob@example.com", "age": 28, "timestamp": 1704067200000}
**Example 5: Update with Parameters**
Query: MATCH (n:Person {id: $userId}) SET n.lastLogin = $loginTime, n.loginCount = n.loginCount + 1
Parameters: {"userId": "user123", "loginTime": 1704067200000}
**Example 6: Relationship with Parameters**
Query: MATCH (a:Person {id: $fromId}), (b:Person {id: $toId}) CREATE (a)-[:KNOWS {since: $year}]->(b)
Parameters: {"fromId": "user1", "toId": "user2", "year": 2024}
### LIMITATIONS
**Cannot be Parameterized:**
- Label names: Cannot use $labelName
- Relationship types: Cannot use $relType
- Property keys: Cannot use $propertyKey
- Keywords: Cannot parameterize MATCH, CREATE, etc.
**Can be Parameterized:**
- Property values ✓
- Numeric constants ✓
- String literals ✓
- Arrays and lists ✓
- WHERE clause conditions ✓
### BEST PRACTICES
1. **Always use parameters for user input** - Never concatenate user data into queries
2. **Use descriptive names** - $userId instead of $id, $minAge instead of $min
3. **Type appropriately** - Use numbers for numeric values, not strings
4. **Validate before passing** - Ensure data types match expected values
5. **Reuse parameter names** - Same parameters work across similar queries for plan caching
Return ONLY valid JSON.`,
placeholder: 'Describe the parameter values...',
generationType: 'neo4j-parameters',
},
},
],
tools: {
access: [
'neo4j_query',
'neo4j_create',
'neo4j_merge',
'neo4j_update',
'neo4j_delete',
'neo4j_execute',
],
config: {
tool: (params) => {
switch (params.operation) {
case 'query':
return 'neo4j_query'
case 'create':
return 'neo4j_create'
case 'merge':
return 'neo4j_merge'
case 'update':
return 'neo4j_update'
case 'delete':
return 'neo4j_delete'
case 'execute':
return 'neo4j_execute'
default:
throw new Error(`Invalid Neo4j operation: ${params.operation}`)
}
},
params: (params) => {
const { operation, parameters, ...rest } = params
let parsedParameters
if (typeof parameters === 'string') {
const trimmed = parameters.trim()
if (trimmed === '') {
parsedParameters = undefined
} else {
try {
parsedParameters = JSON.parse(trimmed)
} catch (parseError) {
const errorMsg =
parseError instanceof Error ? parseError.message : 'Unknown JSON error'
throw new Error(
`Invalid JSON parameters format: ${errorMsg}. Please check your JSON syntax.`
)
}
}
} else if (parameters && typeof parameters === 'object') {
parsedParameters = parameters
} else {
parsedParameters = undefined
}
const connectionConfig = {
host: rest.host,
port: typeof rest.port === 'string' ? Number.parseInt(rest.port, 10) : rest.port || 7687,
database: rest.database || 'neo4j',
username: rest.username || 'neo4j',
password: rest.password,
encryption: rest.encryption || 'disabled',
}
const result: any = { ...connectionConfig }
if (rest.cypherQuery) {
result.cypherQuery = rest.cypherQuery
}
if (parsedParameters !== undefined && parsedParameters !== null) {
result.parameters = parsedParameters
} else {
result.parameters = undefined
}
if (rest.detach !== undefined) {
result.detach = rest.detach === 'true' || rest.detach === true
}
return result
},
},
},
inputs: {
operation: { type: 'string', description: 'Database operation to perform' },
host: { type: 'string', description: 'Neo4j host' },
port: { type: 'string', description: 'Neo4j port (Bolt protocol)' },
database: { type: 'string', description: 'Database name' },
username: { type: 'string', description: 'Neo4j username' },
password: { type: 'string', description: 'Neo4j password' },
encryption: { type: 'string', description: 'Connection encryption mode' },
cypherQuery: { type: 'string', description: 'Cypher query to execute' },
parameters: { type: 'json', description: 'Query parameters as JSON object' },
detach: { type: 'boolean', description: 'Use DETACH DELETE for delete operations' },
},
outputs: {
message: {
type: 'string',
description: 'Success or error message describing the operation outcome',
},
records: {
type: 'array',
description: 'Array of records returned from the query',
},
recordCount: {
type: 'number',
description: 'Number of records returned or affected',
},
summary: {
type: 'json',
description: 'Execution summary with timing and database change counters',
},
},
}

View File

@@ -10,6 +10,7 @@ export const ScheduleBlock: BlockConfig = {
triggerAllowed: true,
name: 'Schedule',
description: 'Trigger workflow execution on a schedule',
docsLink: 'https://docs.sim.ai/triggers/schedule',
longDescription:
'Integrate Schedule into the workflow. Can trigger a workflow on a schedule configuration.',
bestPractices: `

View File

@@ -41,6 +41,7 @@ export const WebhookBlock: BlockConfig = {
category: 'triggers',
icon: WebhookIcon,
bgColor: '#10B981', // Green color for triggers
docsLink: 'https://docs.sim.ai/triggers/webhook',
triggerAllowed: true,
hideFromToolbar: true, // Hidden for backwards compatibility - use generic webhook trigger instead

View File

@@ -6,6 +6,7 @@ import { ApolloBlock } from '@/blocks/blocks/apollo'
import { ArxivBlock } from '@/blocks/blocks/arxiv'
import { AsanaBlock } from '@/blocks/blocks/asana'
import { BrowserUseBlock } from '@/blocks/blocks/browser_use'
import { CalendlyBlock } from '@/blocks/blocks/calendly'
import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger'
import { ClayBlock } from '@/blocks/blocks/clay'
import { ConditionBlock } from '@/blocks/blocks/condition'
@@ -49,6 +50,7 @@ import { MicrosoftTeamsBlock } from '@/blocks/blocks/microsoft_teams'
import { MistralParseBlock } from '@/blocks/blocks/mistral_parse'
import { MongoDBBlock } from '@/blocks/blocks/mongodb'
import { MySQLBlock } from '@/blocks/blocks/mysql'
import { Neo4jBlock } from '@/blocks/blocks/neo4j'
import { NoteBlock } from '@/blocks/blocks/note'
import { NotionBlock } from '@/blocks/blocks/notion'
import { OneDriveBlock } from '@/blocks/blocks/onedrive'
@@ -108,6 +110,7 @@ export const registry: Record<string, BlockConfig> = {
arxiv: ArxivBlock,
asana: AsanaBlock,
browser_use: BrowserUseBlock,
calendly: CalendlyBlock,
clay: ClayBlock,
condition: ConditionBlock,
confluence: ConfluenceBlock,
@@ -148,6 +151,7 @@ export const registry: Record<string, BlockConfig> = {
mistral_parse: MistralParseBlock,
mongodb: MongoDBBlock,
mysql: MySQLBlock,
neo4j: Neo4jBlock,
note: NoteBlock,
notion: NotionBlock,
openai: OpenAIBlock,

View File

@@ -35,6 +35,8 @@ export type GenerationType =
| 'mongodb-sort'
| 'mongodb-documents'
| 'mongodb-update'
| 'neo4j-cypher'
| 'neo4j-parameters'
export type SubBlockType =
| 'short-input' // Single line input

View File

@@ -4032,3 +4032,55 @@ export function ApolloIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function Neo4jIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 128 128' fill='currentColor' xmlns='http://www.w3.org/2000/svg'>
<path
d='M63.333 32.567c-5.2.866-9.566 3-12.833 6.266-3.867 3.867-5.833 8.5-6.5 15.367-.3 3.133-.467 15.467-.2 15.467.067 0 .7-.234 1.4-.534 1.633-.7 5.167-.7 7-.033l1.4.5.167-8.033c.166-8.567.366-9.867 1.966-13.067 1.1-2.133 3.767-4.633 6.034-5.667 2.6-1.2 6.4-1.666 9.333-1.2 6.267 1.034 10 4.434 11.567 10.5.633 2.434.666 3.7.666 17.1v14.434H93.4L93.233 67.9c-.1-14.9-.166-15.9-.866-18.567-1.9-7.4-6.5-12.766-12.934-15.2-3.433-1.3-6.7-1.8-11.2-1.766-2.233.033-4.433.133-4.9.2z'
fill='#000'
/>
<path
d='M22.733 57.2c-2.866 1.433-4.4 4-4.4 7.467 0 1.1.2 2.5.467 3.133.633 1.567 2.433 3.467 4 4.3 1.9 1 5.5 1 7.367.033l1.366-.7 4.267 2.9 4.267 2.934V81.7L35.8 84.633l-4.3 2.934-1.1-.667c-1.6-.933-4.7-1.133-6.6-.4-2 .767-4.067 2.6-4.833 4.333-.834 1.767-.834 5.234 0 7 .7 1.567 2.333 3.3 3.8 4.067.6.3 2.033.6 3.233.7 2.8.2 5.167-.733 6.867-2.733 1.366-1.6 2.266-4.4 2.033-6.334l-.167-1.366 4.3-2.9 4.3-2.9 1.534.7c2.333 1 5.8.766 8-.567 2.4-1.5 3.6-3.633 3.733-6.633.1-2.1 0-2.567-.833-4.2-2.167-4.134-7-5.7-11.134-3.634l-1.233.6-4.233-2.9-4.234-2.9-.1-2.333c-.066-2.8-.866-4.6-2.833-6.233-2.5-2.134-6.233-2.567-9.267-1.067z'
fill='#018BFF'
/>
</svg>
)
}
export function CalendlyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 512 512'
xmlns='http://www.w3.org/2000/svg'
fillRule='evenodd'
clipRule='evenodd'
strokeLinejoin='round'
strokeMiterlimit='2'
>
<g fillRule='nonzero'>
<path
d='M346.955 330.224c-15.875 14.088-35.7 31.619-71.647 31.619h-21.495c-26.012 0-49.672-9.455-66.607-26.593-16.543-16.747-25.649-39.665-25.649-64.545v-29.41c0-24.88 9.106-47.799 25.65-64.545 16.934-17.138 40.594-26.579 66.606-26.579h21.495c35.99 0 55.772 17.516 71.647 31.604 16.484 14.524 30.703 27.218 68.625 27.218a109.162 109.162 0 0017.269-1.38l-.13-.334a129.909 129.909 0 00-7.974-16.382L399.4 146.99c-23.232-40.234-66.304-65.098-112.763-65.096h-50.703c-46.46-.002-89.531 24.862-112.764 65.096l-25.344 43.906c-23.224 40.238-23.224 89.968 0 130.206l25.344 43.906c23.233 40.234 66.305 65.098 112.764 65.096h50.703c46.459.002 89.53-24.862 112.763-65.096l25.345-43.833a129.909 129.909 0 007.973-16.383l.13-.32a107.491 107.491 0 00-17.268-1.452c-37.922 0-52.14 12.621-68.625 27.218'
fill='#006bff'
/>
<path
d='M275.308 176.823h-21.495c-39.592 0-65.605 28.278-65.605 64.471v29.411c0 36.194 26.013 64.472 65.605 64.472h21.495c57.69 0 53.158-58.822 140.272-58.822 8.254-.009 16.49.75 24.603 2.266a130.047 130.047 0 000-45.242 134.431 134.431 0 01-24.603 2.266c-87.143 0-82.583-58.822-140.272-58.822'
fill='#006bff'
/>
<path
d='M490.233 300.116a121.451 121.451 0 00-50.035-21.51v.436a130.296 130.296 0 01-7.262 25.344 95.25 95.25 0 0141.364 17.037c0 .116-.072.261-.116.392-28.788 93.217-115.55 157.228-213.112 157.228-122.358 0-223.044-100.685-223.044-223.043S138.714 32.956 261.072 32.956c97.561 0 184.324 64.012 213.112 157.229 0 .13.073.276.116.392a95.073 95.073 0 01-41.364 17.022 131.112 131.112 0 017.262 25.373 3.166 3.166 0 000 .407 121.415 121.415 0 0050.035-21.495c14.262-10.56 11.503-22.483 9.339-29.542C467.34 77.803 370.064 6 260.67 6c-137.147 0-250 112.854-250 250 0 137.146 112.853 250 250 250 109.394 0 206.67-71.803 238.902-176.342 2.164-7.059 4.923-18.983-9.34-29.542'
fill='#006bff'
/>
<path
d='M432.849 207.599a107.491 107.491 0 01-17.269 1.452c-37.922 0-52.14-12.62-68.61-27.217-15.89-14.089-35.672-31.619-71.662-31.619h-21.495c-26.027 0-49.672 9.455-66.607 26.593-16.543 16.746-25.649 39.665-25.649 64.545v29.41c0 24.88 9.106 47.799 25.65 64.545 16.934 17.138 40.579 26.578 66.606 26.578h21.495c35.99 0 55.772-17.515 71.661-31.604 16.47-14.524 30.69-27.217 68.611-27.217 5.783.001 11.558.463 17.269 1.38a129.303 129.303 0 007.262-25.345c.009-.145.009-.29 0-.436a134.301 134.301 0 00-24.604-2.25c-87.143 0-82.583 58.836-140.271 58.836H253.74c-39.592 0-65.604-28.293-65.604-64.487v-29.469c0-36.193 26.012-64.471 65.604-64.471h21.496c57.688 0 53.157 58.807 140.271 58.807 8.254.015 16.49-.74 24.604-2.251v-.407a131.112 131.112 0 00-7.262-25.373'
fill='#0ae8f0'
/>
<path
d='M432.849 207.599a107.491 107.491 0 01-17.269 1.452c-37.922 0-52.14-12.62-68.61-27.217-15.89-14.089-35.672-31.619-71.662-31.619h-21.495c-26.027 0-49.672 9.455-66.607 26.593-16.543 16.746-25.649 39.665-25.649 64.545v29.41c0 24.88 9.106 47.799 25.65 64.545 16.934 17.138 40.579 26.578 66.606 26.578h21.495c35.99 0 55.772-17.515 71.661-31.604 16.47-14.524 30.69-27.217 68.611-27.217 5.783.001 11.558.463 17.269 1.38a129.303 129.303 0 007.262-25.345c.009-.145.009-.29 0-.436a134.301 134.301 0 00-24.604-2.25c-87.143 0-82.583 58.836-140.271 58.836H253.74c-39.592 0-65.604-28.293-65.604-64.487v-29.469c0-36.193 26.012-64.471 65.604-64.471h21.496c57.688 0 53.157 58.807 140.271 58.807 8.254.015 16.49-.74 24.604-2.251v-.407a131.112 131.112 0 00-7.262-25.373'
fill='#0ae8f0'
/>
</g>
</svg>
)
}

View File

@@ -58,7 +58,7 @@ export function TagInput({
return (
<div
className={cn(
'flex min-h-[2.5rem] flex-wrap gap-1.5 rounded-md border border-input bg-background p-2',
'flex min-h-[34px] flex-wrap gap-1 rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[8px] py-[6px] dark:bg-[var(--surface-9)]',
disabled && 'cursor-not-allowed opacity-50',
className
)}
@@ -68,9 +68,9 @@ export function TagInput({
<Badge
key={tag}
variant='secondary'
className='h-7 gap-1.5 border-0 bg-muted/60 pr-1.5 pl-2.5 hover:bg-muted/80'
className='h-[22px] gap-1 border-0 bg-muted/60 pr-1 pl-2 text-xs hover:bg-muted/80'
>
<span className='text-xs'>{tag}</span>
<span>{tag}</span>
{!disabled && (
<button
type='button'
@@ -95,7 +95,7 @@ export function TagInput({
onBlur={handleBlur}
placeholder={value.length === 0 ? placeholder : ''}
disabled={disabled}
className='h-7 min-w-[120px] flex-1 border-0 bg-transparent p-0 px-1 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0 focus-visible:ring-offset-0'
className='h-[22px] min-w-[120px] flex-1 border-0 bg-transparent p-0 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
)}
</div>

View File

@@ -1337,6 +1337,27 @@ export async function formatWebhookInput(
}
}
if (foundWebhook.provider === 'calendly') {
// Calendly webhook payload format matches the trigger outputs
return {
event: body.event,
created_at: body.created_at,
created_by: body.created_by,
payload: body.payload,
webhook: {
data: {
provider: 'calendly',
path: foundWebhook.path,
providerConfig: foundWebhook.providerConfig,
payload: body,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
},
},
workflowId: foundWorkflow.id,
}
}
// Generic format for other providers
return {
webhook: {

View File

@@ -7,6 +7,7 @@ const teamsLogger = createLogger('TeamsSubscription')
const telegramLogger = createLogger('TelegramWebhook')
const airtableLogger = createLogger('AirtableWebhook')
const typeformLogger = createLogger('TypeformWebhook')
const calendlyLogger = createLogger('CalendlyWebhook')
function getProviderConfig(webhook: any): Record<string, any> {
return (webhook.providerConfig as Record<string, any>) || {}
@@ -611,9 +612,58 @@ export async function deleteTypeformWebhook(webhook: any, requestId: string): Pr
}
}
/**
* Delete a Calendly webhook subscription
* Don't fail webhook deletion if cleanup fails
*/
export async function deleteCalendlyWebhook(webhook: any, requestId: string): Promise<void> {
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey) {
calendlyLogger.warn(
`[${requestId}] Missing apiKey for Calendly webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
if (!externalId) {
calendlyLogger.warn(
`[${requestId}] Missing externalId for Calendly webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
const calendlyApiUrl = `https://api.calendly.com/webhook_subscriptions/${externalId}`
const calendlyResponse = await fetch(calendlyApiUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!calendlyResponse.ok && calendlyResponse.status !== 404) {
const responseBody = await calendlyResponse.json().catch(() => ({}))
calendlyLogger.warn(
`[${requestId}] Failed to delete Calendly webhook (non-fatal): ${calendlyResponse.status}`,
{ response: responseBody }
)
} else {
calendlyLogger.info(
`[${requestId}] Successfully deleted Calendly webhook subscription ${externalId}`
)
}
} catch (error) {
calendlyLogger.warn(`[${requestId}] Error deleting Calendly webhook (non-fatal)`, error)
}
}
/**
* Clean up external webhook subscriptions for a webhook
* Handles Airtable, Teams, Telegram, and Typeform cleanup
* Handles Airtable, Teams, Telegram, Typeform, and Calendly cleanup
* Don't fail deletion if cleanup fails
*/
export async function cleanupExternalWebhook(
@@ -629,5 +679,7 @@ export async function cleanupExternalWebhook(
await deleteTelegramWebhook(webhook, requestId)
} else if (webhook.provider === 'typeform') {
await deleteTypeformWebhook(webhook, requestId)
} else if (webhook.provider === 'calendly') {
await deleteCalendlyWebhook(webhook, requestId)
}
}

View File

@@ -75,6 +75,7 @@
"entities": "6.0.1",
"framer-motion": "^12.5.0",
"fuse.js": "7.1.0",
"fuzzysort": "3.1.0",
"gray-matter": "^4.0.3",
"groq-sdk": "^0.15.0",
"html-to-text": "^9.0.5",

View File

@@ -0,0 +1,87 @@
import type { CalendlyCancelEventParams, CalendlyCancelEventResponse } from '@/tools/calendly/types'
import type { ToolConfig } from '@/tools/types'
export const cancelEventTool: ToolConfig<CalendlyCancelEventParams, CalendlyCancelEventResponse> = {
id: 'calendly_cancel_event',
name: 'Calendly Cancel Event',
description: 'Cancel a scheduled event',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Calendly Personal Access Token',
},
eventUuid: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Scheduled event UUID to cancel (can be full URI or just the UUID)',
},
reason: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Reason for cancellation (will be sent to invitees)',
},
},
request: {
url: (params: CalendlyCancelEventParams) => {
const uuid = params.eventUuid.includes('/')
? params.eventUuid.split('/').pop()
: params.eventUuid
return `https://api.calendly.com/scheduled_events/${uuid}/cancellation`
},
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: CalendlyCancelEventParams) => {
const body: any = {}
if (params.reason) {
body.reason = params.reason
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: data,
}
},
outputs: {
resource: {
type: 'object',
description: 'Cancellation details',
properties: {
canceler_type: {
type: 'string',
description: 'Type of canceler (host or invitee)',
},
canceled_by: {
type: 'string',
description: 'Name of person who canceled',
},
reason: {
type: 'string',
description: 'Cancellation reason',
},
created_at: {
type: 'string',
description: 'ISO timestamp when event was canceled',
},
},
},
},
}

View File

@@ -0,0 +1,151 @@
import type {
CalendlyCreateWebhookParams,
CalendlyCreateWebhookResponse,
} from '@/tools/calendly/types'
import type { ToolConfig } from '@/tools/types'
export const createWebhookTool: ToolConfig<
CalendlyCreateWebhookParams,
CalendlyCreateWebhookResponse
> = {
id: 'calendly_create_webhook',
name: 'Calendly Create Webhook',
description: 'Create a new webhook subscription to receive real-time event notifications',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Calendly Personal Access Token',
},
url: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'URL to receive webhook events (must be HTTPS)',
},
events: {
type: 'json',
required: true,
visibility: 'user-only',
description:
'Array of event types to subscribe to (e.g., ["invitee.created", "invitee.canceled"])',
},
organization: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Organization URI',
},
user: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'User URI (required for user-scoped webhooks)',
},
scope: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Webhook scope: "organization" or "user"',
},
signing_key: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Optional signing key to verify webhook signatures',
},
},
request: {
url: () => 'https://api.calendly.com/webhook_subscriptions',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: CalendlyCreateWebhookParams) => {
const body: any = {
url: params.url,
events: params.events,
organization: params.organization,
scope: params.scope,
}
if (params.user && params.scope === 'user') {
body.user = params.user
}
if (params.signing_key) {
body.signing_key = params.signing_key
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: data,
}
},
outputs: {
resource: {
type: 'object',
description: 'Created webhook subscription details',
properties: {
uri: {
type: 'string',
description: 'Canonical reference to the webhook',
},
callback_url: {
type: 'string',
description: 'URL receiving webhook events',
},
created_at: {
type: 'string',
description: 'ISO timestamp of creation',
},
updated_at: {
type: 'string',
description: 'ISO timestamp of last update',
},
state: {
type: 'string',
description: 'Webhook state (active by default)',
},
events: {
type: 'array',
items: { type: 'string' },
description: 'Subscribed event types',
},
signing_key: {
type: 'string',
description: 'Key to verify webhook signatures',
},
scope: {
type: 'string',
description: 'Webhook scope',
},
organization: {
type: 'string',
description: 'Organization URI',
},
user: {
type: 'string',
description: 'User URI (for user-scoped webhooks)',
},
creator: {
type: 'string',
description: 'URI of user who created the webhook',
},
},
},
},
}

View File

@@ -0,0 +1,76 @@
import type {
CalendlyDeleteWebhookParams,
CalendlyDeleteWebhookResponse,
} from '@/tools/calendly/types'
import type { ToolConfig } from '@/tools/types'
export const deleteWebhookTool: ToolConfig<
CalendlyDeleteWebhookParams,
CalendlyDeleteWebhookResponse
> = {
id: 'calendly_delete_webhook',
name: 'Calendly Delete Webhook',
description: 'Delete a webhook subscription',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Calendly Personal Access Token',
},
webhookUuid: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Webhook subscription UUID to delete (can be full URI or just the UUID)',
},
},
request: {
url: (params: CalendlyDeleteWebhookParams) => {
const uuid = params.webhookUuid.includes('/')
? params.webhookUuid.split('/').pop()
: params.webhookUuid
return `https://api.calendly.com/webhook_subscriptions/${uuid}`
},
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (response.status === 204 || response.status === 200) {
return {
success: true,
output: {
deleted: true,
message: 'Webhook subscription deleted successfully',
},
}
}
const data = await response.json()
return {
success: false,
output: {
deleted: false,
message: data.message || 'Failed to delete webhook subscription',
},
}
},
outputs: {
deleted: {
type: 'boolean',
description: 'Whether the webhook was successfully deleted',
},
message: {
type: 'string',
description: 'Status message',
},
},
}

View File

@@ -0,0 +1,91 @@
import type {
CalendlyGetCurrentUserParams,
CalendlyGetCurrentUserResponse,
} from '@/tools/calendly/types'
import type { ToolConfig } from '@/tools/types'
export const getCurrentUserTool: ToolConfig<
CalendlyGetCurrentUserParams,
CalendlyGetCurrentUserResponse
> = {
id: 'calendly_get_current_user',
name: 'Calendly Get Current User',
description: 'Get information about the currently authenticated Calendly user',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Calendly Personal Access Token',
},
},
request: {
url: () => 'https://api.calendly.com/users/me',
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: data,
}
},
outputs: {
resource: {
type: 'object',
description: 'Current user information',
properties: {
uri: {
type: 'string',
description: 'Canonical reference to the user',
},
name: {
type: 'string',
description: 'User full name',
},
slug: {
type: 'string',
description: 'Unique identifier for the user in URLs',
},
email: {
type: 'string',
description: 'User email address',
},
scheduling_url: {
type: 'string',
description: "URL to the user's scheduling page",
},
timezone: {
type: 'string',
description: 'User timezone',
},
avatar_url: {
type: 'string',
description: 'URL to user avatar image',
},
created_at: {
type: 'string',
description: 'ISO timestamp when user was created',
},
updated_at: {
type: 'string',
description: 'ISO timestamp when user was last updated',
},
current_organization: {
type: 'string',
description: 'URI of current organization',
},
},
},
},
}

View File

@@ -0,0 +1,97 @@
import type {
CalendlyGetEventTypeParams,
CalendlyGetEventTypeResponse,
} from '@/tools/calendly/types'
import type { ToolConfig } from '@/tools/types'
export const getEventTypeTool: ToolConfig<
CalendlyGetEventTypeParams,
CalendlyGetEventTypeResponse
> = {
id: 'calendly_get_event_type',
name: 'Calendly Get Event Type',
description: 'Get detailed information about a specific event type',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Calendly Personal Access Token',
},
eventTypeUuid: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Event type UUID (can be full URI or just the UUID)',
},
},
request: {
url: (params: CalendlyGetEventTypeParams) => {
const uuid = params.eventTypeUuid.includes('/')
? params.eventTypeUuid.split('/').pop()
: params.eventTypeUuid
return `https://api.calendly.com/event_types/${uuid}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: data,
}
},
outputs: {
resource: {
type: 'object',
description: 'Event type details',
properties: {
uri: { type: 'string', description: 'Canonical reference to the event type' },
name: { type: 'string', description: 'Event type name' },
active: { type: 'boolean', description: 'Whether the event type is active' },
booking_method: { type: 'string', description: 'Booking method' },
color: { type: 'string', description: 'Hex color code' },
created_at: { type: 'string', description: 'ISO timestamp of creation' },
custom_questions: {
type: 'array',
description: 'Custom questions for invitees',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Question text' },
type: {
type: 'string',
description: 'Question type (text, single_select, multi_select, etc.)',
},
position: { type: 'number', description: 'Question order' },
enabled: { type: 'boolean', description: 'Whether question is enabled' },
required: { type: 'boolean', description: 'Whether question is required' },
answer_choices: {
type: 'array',
items: { type: 'string' },
description: 'Available answer choices',
},
},
},
},
description_html: { type: 'string', description: 'HTML formatted description' },
description_plain: { type: 'string', description: 'Plain text description' },
duration: { type: 'number', description: 'Duration in minutes' },
scheduling_url: { type: 'string', description: 'URL to scheduling page' },
slug: { type: 'string', description: 'Unique identifier for URLs' },
type: { type: 'string', description: 'Event type classification' },
updated_at: { type: 'string', description: 'ISO timestamp of last update' },
},
},
},
}

View File

@@ -0,0 +1,112 @@
import type {
CalendlyGetScheduledEventParams,
CalendlyGetScheduledEventResponse,
} from '@/tools/calendly/types'
import type { ToolConfig } from '@/tools/types'
export const getScheduledEventTool: ToolConfig<
CalendlyGetScheduledEventParams,
CalendlyGetScheduledEventResponse
> = {
id: 'calendly_get_scheduled_event',
name: 'Calendly Get Scheduled Event',
description: 'Get detailed information about a specific scheduled event',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Calendly Personal Access Token',
},
eventUuid: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Scheduled event UUID (can be full URI or just the UUID)',
},
},
request: {
url: (params: CalendlyGetScheduledEventParams) => {
const uuid = params.eventUuid.includes('/')
? params.eventUuid.split('/').pop()
: params.eventUuid
return `https://api.calendly.com/scheduled_events/${uuid}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: data,
}
},
outputs: {
resource: {
type: 'object',
description: 'Scheduled event details',
properties: {
uri: { type: 'string', description: 'Canonical reference to the event' },
name: { type: 'string', description: 'Event name' },
status: { type: 'string', description: 'Event status (active or canceled)' },
start_time: { type: 'string', description: 'ISO timestamp of event start' },
end_time: { type: 'string', description: 'ISO timestamp of event end' },
event_type: { type: 'string', description: 'URI of the event type' },
location: {
type: 'object',
description: 'Event location details',
properties: {
type: { type: 'string', description: 'Location type' },
location: { type: 'string', description: 'Location description' },
join_url: { type: 'string', description: 'URL to join online meeting' },
},
},
invitees_counter: {
type: 'object',
description: 'Invitee count information',
properties: {
total: { type: 'number', description: 'Total number of invitees' },
active: { type: 'number', description: 'Number of active invitees' },
limit: { type: 'number', description: 'Maximum number of invitees' },
},
},
event_memberships: {
type: 'array',
description: 'Event hosts/members',
items: {
type: 'object',
properties: {
user: { type: 'string', description: 'User URI' },
user_email: { type: 'string', description: 'User email' },
user_name: { type: 'string', description: 'User name' },
},
},
},
event_guests: {
type: 'array',
description: 'Additional guests',
items: {
type: 'object',
properties: {
email: { type: 'string', description: 'Guest email' },
created_at: { type: 'string', description: 'When guest was added' },
updated_at: { type: 'string', description: 'When guest info was updated' },
},
},
},
created_at: { type: 'string', description: 'ISO timestamp of event creation' },
updated_at: { type: 'string', description: 'ISO timestamp of last update' },
},
},
},
}

View File

@@ -0,0 +1,21 @@
import { cancelEventTool } from '@/tools/calendly/cancel_event'
import { createWebhookTool } from '@/tools/calendly/create_webhook'
import { deleteWebhookTool } from '@/tools/calendly/delete_webhook'
import { getCurrentUserTool } from '@/tools/calendly/get_current_user'
import { getEventTypeTool } from '@/tools/calendly/get_event_type'
import { getScheduledEventTool } from '@/tools/calendly/get_scheduled_event'
import { listEventInviteesTool } from '@/tools/calendly/list_event_invitees'
import { listEventTypesTool } from '@/tools/calendly/list_event_types'
import { listScheduledEventsTool } from '@/tools/calendly/list_scheduled_events'
import { listWebhooksTool } from '@/tools/calendly/list_webhooks'
export const calendlyGetCurrentUserTool = getCurrentUserTool
export const calendlyListEventTypesTool = listEventTypesTool
export const calendlyGetEventTypeTool = getEventTypeTool
export const calendlyListScheduledEventsTool = listScheduledEventsTool
export const calendlyGetScheduledEventTool = getScheduledEventTool
export const calendlyListEventInviteesTool = listEventInviteesTool
export const calendlyCancelEventTool = cancelEventTool
export const calendlyListWebhooksTool = listWebhooksTool
export const calendlyCreateWebhookTool = createWebhookTool
export const calendlyDeleteWebhookTool = deleteWebhookTool

View File

@@ -0,0 +1,154 @@
import type {
CalendlyListEventInviteesParams,
CalendlyListEventInviteesResponse,
} from '@/tools/calendly/types'
import type { ToolConfig } from '@/tools/types'
export const listEventInviteesTool: ToolConfig<
CalendlyListEventInviteesParams,
CalendlyListEventInviteesResponse
> = {
id: 'calendly_list_event_invitees',
name: 'Calendly List Event Invitees',
description: 'Retrieve a list of invitees for a scheduled event',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Calendly Personal Access Token',
},
eventUuid: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Scheduled event UUID (can be full URI or just the UUID)',
},
count: {
type: 'number',
required: false,
visibility: 'user-only',
description: 'Number of results per page (default: 20, max: 100)',
},
email: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Filter invitees by email address',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Page token for pagination',
},
sort: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Sort order for results (e.g., "created_at:asc", "created_at:desc")',
},
status: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Filter by status ("active" or "canceled")',
},
},
request: {
url: (params: CalendlyListEventInviteesParams) => {
const uuid = params.eventUuid.includes('/')
? params.eventUuid.split('/').pop()
: params.eventUuid
const url = `https://api.calendly.com/scheduled_events/${uuid}/invitees`
const queryParams = []
if (params.count) {
queryParams.push(`count=${Number(params.count)}`)
}
if (params.email) {
queryParams.push(`email=${encodeURIComponent(params.email)}`)
}
if (params.pageToken) {
queryParams.push(`page_token=${encodeURIComponent(params.pageToken)}`)
}
if (params.sort) {
queryParams.push(`sort=${encodeURIComponent(params.sort)}`)
}
if (params.status) {
queryParams.push(`status=${encodeURIComponent(params.status)}`)
}
return queryParams.length > 0 ? `${url}?${queryParams.join('&')}` : url
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: data,
}
},
outputs: {
collection: {
type: 'array',
description: 'Array of invitee objects',
items: {
type: 'object',
properties: {
uri: { type: 'string', description: 'Canonical reference to the invitee' },
email: { type: 'string', description: 'Invitee email address' },
name: { type: 'string', description: 'Invitee full name' },
first_name: { type: 'string', description: 'Invitee first name' },
last_name: { type: 'string', description: 'Invitee last name' },
status: { type: 'string', description: 'Invitee status (active or canceled)' },
questions_and_answers: {
type: 'array',
description: 'Responses to custom questions',
items: {
type: 'object',
properties: {
question: { type: 'string', description: 'Question text' },
answer: { type: 'string', description: 'Invitee answer' },
position: { type: 'number', description: 'Question order' },
},
},
},
timezone: { type: 'string', description: 'Invitee timezone' },
event: { type: 'string', description: 'URI of the scheduled event' },
created_at: { type: 'string', description: 'ISO timestamp when invitee was created' },
updated_at: { type: 'string', description: 'ISO timestamp when invitee was updated' },
cancel_url: { type: 'string', description: 'URL to cancel the booking' },
reschedule_url: { type: 'string', description: 'URL to reschedule the booking' },
rescheduled: { type: 'boolean', description: 'Whether invitee rescheduled' },
},
},
},
pagination: {
type: 'object',
description: 'Pagination information',
properties: {
count: { type: 'number', description: 'Number of results in this page' },
next_page: { type: 'string', description: 'URL to next page (if available)' },
previous_page: { type: 'string', description: 'URL to previous page (if available)' },
next_page_token: { type: 'string', description: 'Token for next page' },
previous_page_token: { type: 'string', description: 'Token for previous page' },
},
},
},
}

View File

@@ -0,0 +1,147 @@
import type {
CalendlyListEventTypesParams,
CalendlyListEventTypesResponse,
} from '@/tools/calendly/types'
import type { ToolConfig } from '@/tools/types'
export const listEventTypesTool: ToolConfig<
CalendlyListEventTypesParams,
CalendlyListEventTypesResponse
> = {
id: 'calendly_list_event_types',
name: 'Calendly List Event Types',
description: 'Retrieve a list of all event types for a user or organization',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Calendly Personal Access Token',
},
user: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Return only event types that belong to this user (URI format)',
},
organization: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Return only event types that belong to this organization (URI format)',
},
count: {
type: 'number',
required: false,
visibility: 'user-only',
description: 'Number of results per page (default: 20, max: 100)',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Page token for pagination',
},
sort: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Sort order for results (e.g., "name:asc", "name:desc")',
},
active: {
type: 'boolean',
required: false,
visibility: 'user-only',
description:
'When true, show only active event types. When false or unchecked, show all event types (both active and inactive).',
},
},
request: {
url: (params: CalendlyListEventTypesParams) => {
const url = 'https://api.calendly.com/event_types'
const queryParams = []
if (params.user) {
queryParams.push(`user=${encodeURIComponent(params.user)}`)
}
if (params.organization) {
queryParams.push(`organization=${encodeURIComponent(params.organization)}`)
}
if (params.count) {
queryParams.push(`count=${Number(params.count)}`)
}
if (params.pageToken) {
queryParams.push(`page_token=${encodeURIComponent(params.pageToken)}`)
}
if (params.sort) {
queryParams.push(`sort=${encodeURIComponent(params.sort)}`)
}
if (params.active === true) {
queryParams.push('active=true')
}
return queryParams.length > 0 ? `${url}?${queryParams.join('&')}` : url
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: data,
}
},
outputs: {
collection: {
type: 'array',
description: 'Array of event type objects',
items: {
type: 'object',
properties: {
uri: { type: 'string', description: 'Canonical reference to the event type' },
name: { type: 'string', description: 'Event type name' },
active: { type: 'boolean', description: 'Whether the event type is active' },
booking_method: {
type: 'string',
description: 'Booking method (e.g., "round_robin_or_collect", "collective")',
},
color: { type: 'string', description: 'Hex color code' },
created_at: { type: 'string', description: 'ISO timestamp of creation' },
description_html: { type: 'string', description: 'HTML formatted description' },
description_plain: { type: 'string', description: 'Plain text description' },
duration: { type: 'number', description: 'Duration in minutes' },
scheduling_url: { type: 'string', description: 'URL to scheduling page' },
slug: { type: 'string', description: 'Unique identifier for URLs' },
type: { type: 'string', description: 'Event type classification' },
updated_at: { type: 'string', description: 'ISO timestamp of last update' },
},
},
},
pagination: {
type: 'object',
description: 'Pagination information',
properties: {
count: { type: 'number', description: 'Number of results in this page' },
next_page: { type: 'string', description: 'URL to next page (if available)' },
previous_page: { type: 'string', description: 'URL to previous page (if available)' },
next_page_token: { type: 'string', description: 'Token for next page' },
previous_page_token: { type: 'string', description: 'Token for previous page' },
},
},
},
}

View File

@@ -0,0 +1,200 @@
import type {
CalendlyListScheduledEventsParams,
CalendlyListScheduledEventsResponse,
} from '@/tools/calendly/types'
import type { ToolConfig } from '@/tools/types'
export const listScheduledEventsTool: ToolConfig<
CalendlyListScheduledEventsParams,
CalendlyListScheduledEventsResponse
> = {
id: 'calendly_list_scheduled_events',
name: 'Calendly List Scheduled Events',
description: 'Retrieve a list of scheduled events for a user or organization',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Calendly Personal Access Token',
},
user: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Return events that belong to this user (URI format). Either "user" or "organization" must be provided.',
},
organization: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Return events that belong to this organization (URI format). Either "user" or "organization" must be provided.',
},
invitee_email: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Return events where invitee has this email',
},
count: {
type: 'number',
required: false,
visibility: 'user-only',
description: 'Number of results per page (default: 20, max: 100)',
},
max_start_time: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Return events with start time before this time (ISO 8601 format)',
},
min_start_time: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Return events with start time after this time (ISO 8601 format)',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Page token for pagination',
},
sort: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Sort order for results (e.g., "start_time:asc", "start_time:desc")',
},
status: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Filter by status ("active" or "canceled")',
},
},
request: {
url: (params: CalendlyListScheduledEventsParams) => {
const url = 'https://api.calendly.com/scheduled_events'
const queryParams = []
if (!params.user && !params.organization) {
throw new Error(
'At least one of "user" or "organization" parameter is required. Please provide either a user URI or organization URI.'
)
}
if (params.user) {
queryParams.push(`user=${encodeURIComponent(params.user)}`)
}
if (params.organization) {
queryParams.push(`organization=${encodeURIComponent(params.organization)}`)
}
if (params.invitee_email) {
queryParams.push(`invitee_email=${encodeURIComponent(params.invitee_email)}`)
}
if (params.count) {
queryParams.push(`count=${Number(params.count)}`)
}
if (params.max_start_time) {
queryParams.push(`max_start_time=${encodeURIComponent(params.max_start_time)}`)
}
if (params.min_start_time) {
queryParams.push(`min_start_time=${encodeURIComponent(params.min_start_time)}`)
}
if (params.pageToken) {
queryParams.push(`page_token=${encodeURIComponent(params.pageToken)}`)
}
if (params.sort) {
queryParams.push(`sort=${encodeURIComponent(params.sort)}`)
}
if (params.status) {
queryParams.push(`status=${encodeURIComponent(params.status)}`)
}
return queryParams.length > 0 ? `${url}?${queryParams.join('&')}` : url
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: data,
}
},
outputs: {
collection: {
type: 'array',
description: 'Array of scheduled event objects',
items: {
type: 'object',
properties: {
uri: { type: 'string', description: 'Canonical reference to the event' },
name: { type: 'string', description: 'Event name' },
status: { type: 'string', description: 'Event status (active or canceled)' },
start_time: { type: 'string', description: 'ISO timestamp of event start' },
end_time: { type: 'string', description: 'ISO timestamp of event end' },
event_type: { type: 'string', description: 'URI of the event type' },
location: {
type: 'object',
description: 'Event location details',
properties: {
type: {
type: 'string',
description: 'Location type (e.g., "zoom", "google_meet", "physical")',
},
location: { type: 'string', description: 'Location description' },
join_url: {
type: 'string',
description: 'URL to join online meeting (if applicable)',
},
},
},
invitees_counter: {
type: 'object',
description: 'Invitee count information',
properties: {
total: { type: 'number', description: 'Total number of invitees' },
active: { type: 'number', description: 'Number of active invitees' },
limit: { type: 'number', description: 'Maximum number of invitees' },
},
},
created_at: { type: 'string', description: 'ISO timestamp of event creation' },
updated_at: { type: 'string', description: 'ISO timestamp of last update' },
},
},
},
pagination: {
type: 'object',
description: 'Pagination information',
properties: {
count: { type: 'number', description: 'Number of results in this page' },
next_page: { type: 'string', description: 'URL to next page (if available)' },
previous_page: { type: 'string', description: 'URL to previous page (if available)' },
next_page_token: { type: 'string', description: 'Token for next page' },
previous_page_token: { type: 'string', description: 'Token for previous page' },
},
},
},
}

View File

@@ -0,0 +1,133 @@
import type {
CalendlyListWebhooksParams,
CalendlyListWebhooksResponse,
} from '@/tools/calendly/types'
import type { ToolConfig } from '@/tools/types'
export const listWebhooksTool: ToolConfig<
CalendlyListWebhooksParams,
CalendlyListWebhooksResponse
> = {
id: 'calendly_list_webhooks',
name: 'Calendly List Webhooks',
description: 'Retrieve a list of webhook subscriptions for an organization',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Calendly Personal Access Token',
},
organization: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Organization URI to list webhooks for',
},
count: {
type: 'number',
required: false,
visibility: 'user-only',
description: 'Number of results per page (default: 20, max: 100)',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Page token for pagination',
},
scope: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Filter by scope ("organization" or "user")',
},
user: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Filter webhooks by user URI (for user-scoped webhooks)',
},
},
request: {
url: (params: CalendlyListWebhooksParams) => {
const url = 'https://api.calendly.com/webhook_subscriptions'
const queryParams = []
queryParams.push(`organization=${encodeURIComponent(params.organization)}`)
if (params.count) {
queryParams.push(`count=${Number(params.count)}`)
}
if (params.pageToken) {
queryParams.push(`page_token=${encodeURIComponent(params.pageToken)}`)
}
if (params.scope) {
queryParams.push(`scope=${encodeURIComponent(params.scope)}`)
}
if (params.user) {
queryParams.push(`user=${encodeURIComponent(params.user)}`)
}
return `${url}?${queryParams.join('&')}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: data,
}
},
outputs: {
collection: {
type: 'array',
description: 'Array of webhook subscription objects',
items: {
type: 'object',
properties: {
uri: { type: 'string', description: 'Canonical reference to the webhook' },
callback_url: { type: 'string', description: 'URL to receive webhook events' },
created_at: { type: 'string', description: 'ISO timestamp of creation' },
updated_at: { type: 'string', description: 'ISO timestamp of last update' },
state: { type: 'string', description: 'Webhook state (active, disabled, etc.)' },
events: {
type: 'array',
items: { type: 'string' },
description: 'Event types this webhook subscribes to',
},
signing_key: { type: 'string', description: 'Key to verify webhook signatures' },
scope: { type: 'string', description: 'Webhook scope (organization or user)' },
organization: { type: 'string', description: 'Organization URI' },
user: { type: 'string', description: 'User URI (for user-scoped webhooks)' },
creator: { type: 'string', description: 'URI of user who created the webhook' },
},
},
},
pagination: {
type: 'object',
description: 'Pagination information',
properties: {
count: { type: 'number', description: 'Number of results in this page' },
next_page: { type: 'string', description: 'URL to next page (if available)' },
previous_page: { type: 'string', description: 'URL to previous page (if available)' },
next_page_token: { type: 'string', description: 'Token for next page' },
previous_page_token: { type: 'string', description: 'Token for previous page' },
},
},
},
}

View File

@@ -0,0 +1,385 @@
import type { ToolResponse } from '@/tools/types'
export interface CalendlyGetCurrentUserParams {
apiKey: string
}
export interface CalendlyGetCurrentUserResponse extends ToolResponse {
output: {
resource: {
uri: string
name: string
slug: string
email: string
scheduling_url: string
timezone: string
avatar_url: string
created_at: string
updated_at: string
current_organization: string
resource_type: string
locale: string
}
}
}
export interface CalendlyListEventTypesParams {
apiKey: string
user?: string
organization?: string
count?: number
pageToken?: string
sort?: string
active?: boolean
}
export interface CalendlyListEventTypesResponse extends ToolResponse {
output: {
collection: Array<{
uri: string
name: string
active: boolean
booking_method: string
color: string
created_at: string
description_html: string
description_plain: string
duration: number
internal_note: string
kind: string
pooling_type: string
profile: {
name: string
owner: string
type: string
}
scheduling_url: string
slug: string
type: string
updated_at: string
}>
pagination: {
count: number
next_page: string | null
previous_page: string | null
next_page_token: string | null
previous_page_token: string | null
}
}
}
export interface CalendlyGetEventTypeParams {
apiKey: string
eventTypeUuid: string
}
export interface CalendlyGetEventTypeResponse extends ToolResponse {
output: {
resource: {
uri: string
name: string
active: boolean
booking_method: string
color: string
created_at: string
custom_questions: Array<{
name: string
type: string
position: number
enabled: boolean
required: boolean
answer_choices: string[]
include_other: boolean
}>
deleted_at: string | null
description_html: string
description_plain: string
duration: number
internal_note: string
kind: string
pooling_type: string
profile: {
name: string
owner: string
type: string
}
scheduling_url: string
slug: string
type: string
updated_at: string
}
}
}
export interface CalendlyListScheduledEventsParams {
apiKey: string
user?: string
organization?: string
invitee_email?: string
count?: number
max_start_time?: string
min_start_time?: string
pageToken?: string
sort?: string
status?: string
}
export interface CalendlyListScheduledEventsResponse extends ToolResponse {
output: {
collection: Array<{
uri: string
name: string
status: string
start_time: string
end_time: string
event_type: string
location: {
type: string
location: string
join_url?: string
data?: Record<string, any>
}
invitees_counter: {
total: number
active: number
limit: number
}
created_at: string
updated_at: string
event_memberships: Array<{
user: string
user_email: string
user_name: string
}>
event_guests: Array<{
email: string
created_at: string
updated_at: string
}>
cancellation?: {
canceled_by: string
reason: string
canceler_type: string
}
}>
pagination: {
count: number
next_page: string | null
previous_page: string | null
next_page_token: string | null
previous_page_token: string | null
}
}
}
export interface CalendlyGetScheduledEventParams {
apiKey: string
eventUuid: string
}
export interface CalendlyGetScheduledEventResponse extends ToolResponse {
output: {
resource: {
uri: string
name: string
status: string
start_time: string
end_time: string
event_type: string
location: {
type: string
location: string
join_url?: string
data?: Record<string, any>
}
invitees_counter: {
total: number
active: number
limit: number
}
created_at: string
updated_at: string
event_memberships: Array<{
user: string
user_email: string
user_name: string
}>
event_guests: Array<{
email: string
created_at: string
updated_at: string
}>
cancellation?: {
canceled_by: string
reason: string
canceler_type: string
}
}
}
}
export interface CalendlyListEventInviteesParams {
apiKey: string
eventUuid: string
count?: number
email?: string
pageToken?: string
sort?: string
status?: string
}
export interface CalendlyListEventInviteesResponse extends ToolResponse {
output: {
collection: Array<{
uri: string
email: string
name: string
first_name: string | null
last_name: string | null
status: string
questions_and_answers: Array<{
question: string
answer: string
position: number
}>
timezone: string
event: string
created_at: string
updated_at: string
tracking: {
utm_campaign: string | null
utm_source: string | null
utm_medium: string | null
utm_content: string | null
utm_term: string | null
salesforce_uuid: string | null
}
text_reminder_number: string | null
rescheduled: boolean
old_invitee: string | null
new_invitee: string | null
cancel_url: string
reschedule_url: string
cancellation?: {
canceled_by: string
reason: string
canceler_type: string
}
payment?: {
id: string
provider: string
amount: number
currency: string
terms: string
successful: boolean
}
no_show?: {
created_at: string
}
reconfirmation?: {
created_at: string
confirmed_at: string | null
}
}>
pagination: {
count: number
next_page: string | null
previous_page: string | null
next_page_token: string | null
previous_page_token: string | null
}
}
}
export interface CalendlyCancelEventParams {
apiKey: string
eventUuid: string
reason?: string
}
export interface CalendlyCancelEventResponse extends ToolResponse {
output: {
resource: {
canceler_type: string
canceled_by: string
reason: string | null
created_at: string
}
}
}
export interface CalendlyListWebhooksParams {
apiKey: string
organization: string
count?: number
pageToken?: string
scope?: string
user?: string
}
export interface CalendlyListWebhooksResponse extends ToolResponse {
output: {
collection: Array<{
uri: string
callback_url: string
created_at: string
updated_at: string
retry_started_at: string | null
state: string
events: string[]
signing_key: string
scope: string
organization: string
user?: string
creator: string
}>
pagination: {
count: number
next_page: string | null
previous_page: string | null
next_page_token: string | null
previous_page_token: string | null
}
}
}
export interface CalendlyCreateWebhookParams {
apiKey: string
url: string
events: string[]
organization: string
user?: string
scope: string
signing_key?: string
}
export interface CalendlyCreateWebhookResponse extends ToolResponse {
output: {
resource: {
uri: string
callback_url: string
created_at: string
updated_at: string
retry_started_at: string | null
state: string
events: string[]
signing_key: string
scope: string
organization: string
user?: string
creator: string
}
}
}
export interface CalendlyDeleteWebhookParams {
apiKey: string
webhookUuid: string
}
export interface CalendlyDeleteWebhookResponse extends ToolResponse {
output: {
deleted: boolean
message: string
}
}

View File

@@ -11,10 +11,16 @@ export const memoryAddTool: ToolConfig<any, MemoryResponse> = {
params: {
conversationId: {
type: 'string',
required: true,
required: false,
description:
'Conversation identifier (e.g., user-123, session-abc). If a memory with this conversationId already exists for this block, the new message will be appended to it.',
},
id: {
type: 'string',
required: false,
description:
'Legacy parameter for conversation identifier. Use conversationId instead. Provided for backwards compatibility.',
},
role: {
type: 'string',
required: true,
@@ -29,7 +35,7 @@ export const memoryAddTool: ToolConfig<any, MemoryResponse> = {
type: 'string',
required: false,
description:
'Optional block ID. If not provided, uses the current block ID from execution context.',
'Optional block ID. If not provided, uses the current block ID from execution context, or defaults to "default".',
},
},
@@ -57,30 +63,20 @@ export const memoryAddTool: ToolConfig<any, MemoryResponse> = {
}
}
const blockId = params.blockId || contextBlockId
if (!blockId) {
return {
_errorResponse: {
status: 400,
data: {
success: false,
error: {
message:
'blockId is required. Either provide it as a parameter or ensure it is available in execution context.',
},
},
},
}
}
// Use 'id' as fallback for 'conversationId' for backwards compatibility
const conversationId = params.conversationId || params.id
if (!params.conversationId || params.conversationId.trim() === '') {
// Default blockId to 'default' if not provided in params or context
const blockId = params.blockId || contextBlockId || 'default'
if (!conversationId || conversationId.trim() === '') {
return {
_errorResponse: {
status: 400,
data: {
success: false,
error: {
message: 'conversationId is required',
message: 'conversationId or id is required',
},
},
},
@@ -101,7 +97,7 @@ export const memoryAddTool: ToolConfig<any, MemoryResponse> = {
}
}
const key = buildMemoryKey(params.conversationId, blockId)
const key = buildMemoryKey(conversationId, blockId)
const body: Record<string, any> = {
key,

View File

@@ -15,6 +15,12 @@ export const memoryDeleteTool: ToolConfig<any, MemoryResponse> = {
description:
'Conversation identifier (e.g., user-123, session-abc). If provided alone, deletes all memories for this conversation across all blocks.',
},
id: {
type: 'string',
required: false,
description:
'Legacy parameter for conversation identifier. Use conversationId instead. Provided for backwards compatibility.',
},
blockId: {
type: 'string',
required: false,
@@ -47,14 +53,18 @@ export const memoryDeleteTool: ToolConfig<any, MemoryResponse> = {
}
}
if (!params.conversationId && !params.blockId && !params.blockName) {
// Use 'id' as fallback for 'conversationId' for backwards compatibility
const conversationId = params.conversationId || params.id
if (!conversationId && !params.blockId && !params.blockName) {
return {
_errorResponse: {
status: 400,
data: {
success: false,
error: {
message: 'At least one of conversationId, blockId, or blockName must be provided',
message:
'At least one of conversationId, id, blockId, or blockName must be provided',
},
},
},
@@ -64,8 +74,8 @@ export const memoryDeleteTool: ToolConfig<any, MemoryResponse> = {
const url = new URL('/api/memory', 'http://dummy')
url.searchParams.set('workflowId', workflowId)
if (params.conversationId) {
url.searchParams.set('conversationId', params.conversationId)
if (conversationId) {
url.searchParams.set('conversationId', conversationId)
}
if (params.blockId) {
url.searchParams.set('blockId', params.blockId)

View File

@@ -16,6 +16,12 @@ export const memoryGetTool: ToolConfig<any, MemoryResponse> = {
description:
'Conversation identifier (e.g., user-123, session-abc). If provided alone, returns all memories for this conversation across all blocks.',
},
id: {
type: 'string',
required: false,
description:
'Legacy parameter for conversation identifier. Use conversationId instead. Provided for backwards compatibility.',
},
blockId: {
type: 'string',
required: false,
@@ -48,14 +54,18 @@ export const memoryGetTool: ToolConfig<any, MemoryResponse> = {
}
}
if (!params.conversationId && !params.blockId && !params.blockName) {
// Use 'id' as fallback for 'conversationId' for backwards compatibility
const conversationId = params.conversationId || params.id
if (!conversationId && !params.blockId && !params.blockName) {
return {
_errorResponse: {
status: 400,
data: {
success: false,
error: {
message: 'At least one of conversationId, blockId, or blockName must be provided',
message:
'At least one of conversationId, id, blockId, or blockName must be provided',
},
},
},
@@ -64,10 +74,11 @@ export const memoryGetTool: ToolConfig<any, MemoryResponse> = {
let query = ''
if (params.conversationId && params.blockId) {
query = buildMemoryKey(params.conversationId, params.blockId)
} else if (params.conversationId) {
query = `${params.conversationId}:`
if (conversationId && params.blockId) {
query = buildMemoryKey(conversationId, params.blockId)
} else if (conversationId) {
// Also check for legacy format (conversationId without blockId)
query = `${conversationId}:`
} else if (params.blockId) {
query = `:${params.blockId}`
}

View File

@@ -1,18 +1,36 @@
/**
* Parse memory key into conversationId and blockId
* Key format: conversationId:blockId
* Supports two formats:
* - New format: conversationId:blockId (splits on LAST colon to handle IDs with colons)
* - Legacy format: id (without colon, treated as conversationId with blockId='default')
* @param key The memory key to parse
* @returns Object with conversationId and blockId, or null if invalid
*/
export function parseMemoryKey(key: string): { conversationId: string; blockId: string } | null {
const parts = key.split(':')
if (parts.length !== 2) {
if (!key) {
return null
}
const lastColonIndex = key.lastIndexOf(':')
// Legacy format: no colon found
if (lastColonIndex === -1) {
return {
conversationId: parts[0],
blockId: parts[1],
conversationId: key,
blockId: 'default',
}
}
// Invalid: colon at start or end
if (lastColonIndex === 0 || lastColonIndex === key.length - 1) {
return null
}
// New format: split on last colon to handle IDs with colons
// This allows conversationIds like "user:123" to work correctly
return {
conversationId: key.substring(0, lastColonIndex),
blockId: key.substring(lastColonIndex + 1),
}
}

View File

@@ -0,0 +1,104 @@
import type { Neo4jCreateParams, Neo4jResponse } from '@/tools/neo4j/types'
import type { ToolConfig } from '@/tools/types'
export const createTool: ToolConfig<Neo4jCreateParams, Neo4jResponse> = {
id: 'neo4j_create',
name: 'Neo4j Create',
description:
'Execute CREATE statements to add new nodes and relationships to Neo4j graph database',
version: '1.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'Neo4j server port (default: 7687 for Bolt protocol)',
},
database: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Database name to connect to',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j password',
},
encryption: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Connection encryption mode (enabled, disabled)',
},
cypherQuery: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Cypher CREATE statement to execute',
},
parameters: {
type: 'object',
required: false,
visibility: 'user-or-llm',
description: 'Parameters for the Cypher query as a JSON object',
},
},
request: {
url: '/api/tools/neo4j/create',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port),
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption || 'disabled',
cypherQuery: params.cypherQuery,
parameters: params.parameters,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Neo4j create operation failed')
}
return {
success: true,
output: {
message: data.message || 'Create operation executed successfully',
summary: data.summary,
},
error: undefined,
}
},
outputs: {
message: { type: 'string', description: 'Operation status message' },
summary: {
type: 'json',
description: 'Creation summary with counters for nodes and relationships created',
},
},
}

View File

@@ -0,0 +1,111 @@
import type { Neo4jDeleteParams, Neo4jResponse } from '@/tools/neo4j/types'
import type { ToolConfig } from '@/tools/types'
export const deleteTool: ToolConfig<Neo4jDeleteParams, Neo4jResponse> = {
id: 'neo4j_delete',
name: 'Neo4j Delete',
description:
'Execute DELETE or DETACH DELETE statements to remove nodes and relationships from Neo4j',
version: '1.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'Neo4j server port (default: 7687 for Bolt protocol)',
},
database: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Database name to connect to',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j password',
},
encryption: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Connection encryption mode (enabled, disabled)',
},
cypherQuery: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Cypher query with MATCH and DELETE/DETACH DELETE statements',
},
parameters: {
type: 'object',
required: false,
visibility: 'user-or-llm',
description: 'Parameters for the Cypher query as a JSON object',
},
detach: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to use DETACH DELETE to remove relationships before deleting nodes',
},
},
request: {
url: '/api/tools/neo4j/delete',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port),
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption || 'disabled',
cypherQuery: params.cypherQuery,
parameters: params.parameters,
detach: params.detach,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Neo4j delete operation failed')
}
return {
success: true,
output: {
message: data.message || 'Delete operation executed successfully',
summary: data.summary,
},
error: undefined,
}
},
outputs: {
message: { type: 'string', description: 'Operation status message' },
summary: {
type: 'json',
description: 'Delete summary with counters for nodes and relationships deleted',
},
},
}

View File

@@ -0,0 +1,104 @@
import type { Neo4jExecuteParams, Neo4jResponse } from '@/tools/neo4j/types'
import type { ToolConfig } from '@/tools/types'
export const executeTool: ToolConfig<Neo4jExecuteParams, Neo4jResponse> = {
id: 'neo4j_execute',
name: 'Neo4j Execute',
description: 'Execute arbitrary Cypher queries on Neo4j graph database for complex operations',
version: '1.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'Neo4j server port (default: 7687 for Bolt protocol)',
},
database: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Database name to connect to',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j password',
},
encryption: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Connection encryption mode (enabled, disabled)',
},
cypherQuery: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Cypher query to execute (any valid Cypher statement)',
},
parameters: {
type: 'object',
required: false,
visibility: 'user-or-llm',
description: 'Parameters for the Cypher query as a JSON object',
},
},
request: {
url: '/api/tools/neo4j/execute',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port),
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption || 'disabled',
cypherQuery: params.cypherQuery,
parameters: params.parameters,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Neo4j execute operation failed')
}
return {
success: true,
output: {
message: data.message || 'Query executed successfully',
records: data.records || [],
recordCount: data.recordCount || 0,
summary: data.summary,
},
error: undefined,
}
},
outputs: {
message: { type: 'string', description: 'Operation status message' },
records: { type: 'array', description: 'Array of records returned from the query' },
recordCount: { type: 'number', description: 'Number of records returned' },
summary: { type: 'json', description: 'Execution summary with timing and counters' },
},
}

View File

@@ -0,0 +1,7 @@
export { createTool } from './create'
export { deleteTool } from './delete'
export { executeTool } from './execute'
export { mergeTool } from './merge'
export { queryTool } from './query'
export * from './types'
export { updateTool } from './update'

View File

@@ -0,0 +1,104 @@
import type { Neo4jMergeParams, Neo4jResponse } from '@/tools/neo4j/types'
import type { ToolConfig } from '@/tools/types'
export const mergeTool: ToolConfig<Neo4jMergeParams, Neo4jResponse> = {
id: 'neo4j_merge',
name: 'Neo4j Merge',
description:
'Execute MERGE statements to find or create nodes and relationships in Neo4j (upsert operation)',
version: '1.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'Neo4j server port (default: 7687 for Bolt protocol)',
},
database: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Database name to connect to',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j password',
},
encryption: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Connection encryption mode (enabled, disabled)',
},
cypherQuery: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Cypher MERGE statement to execute',
},
parameters: {
type: 'object',
required: false,
visibility: 'user-or-llm',
description: 'Parameters for the Cypher query as a JSON object',
},
},
request: {
url: '/api/tools/neo4j/merge',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port),
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption || 'disabled',
cypherQuery: params.cypherQuery,
parameters: params.parameters,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Neo4j merge operation failed')
}
return {
success: true,
output: {
message: data.message || 'Merge operation executed successfully',
summary: data.summary,
},
error: undefined,
}
},
outputs: {
message: { type: 'string', description: 'Operation status message' },
summary: {
type: 'json',
description: 'Merge summary with counters for nodes/relationships created or matched',
},
},
}

View File

@@ -0,0 +1,106 @@
import type { Neo4jQueryParams, Neo4jResponse } from '@/tools/neo4j/types'
import type { ToolConfig } from '@/tools/types'
export const queryTool: ToolConfig<Neo4jQueryParams, Neo4jResponse> = {
id: 'neo4j_query',
name: 'Neo4j Query',
description:
'Execute MATCH queries to read nodes and relationships from Neo4j graph database. For best performance and to prevent large result sets, include LIMIT in your query (e.g., "MATCH (n:User) RETURN n LIMIT 100") or use LIMIT $limit with a limit parameter.',
version: '1.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'Neo4j server port (default: 7687 for Bolt protocol)',
},
database: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Database name to connect to',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j password',
},
encryption: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Connection encryption mode (enabled, disabled)',
},
cypherQuery: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Cypher query to execute (typically MATCH statements)',
},
parameters: {
type: 'object',
required: false,
visibility: 'user-or-llm',
description:
'Parameters for the Cypher query as a JSON object. Use for any dynamic values including LIMIT (e.g., query: "MATCH (n) RETURN n LIMIT $limit", parameters: {limit: 100}).',
},
},
request: {
url: '/api/tools/neo4j/query',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port),
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption || 'disabled',
cypherQuery: params.cypherQuery,
parameters: params.parameters,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Neo4j query failed')
}
return {
success: true,
output: {
message: data.message || 'Query executed successfully',
records: data.records || [],
recordCount: data.recordCount || 0,
summary: data.summary,
},
error: undefined,
}
},
outputs: {
message: { type: 'string', description: 'Operation status message' },
records: { type: 'array', description: 'Array of records returned from the query' },
recordCount: { type: 'number', description: 'Number of records returned' },
summary: { type: 'json', description: 'Query execution summary with timing and counters' },
},
}

View File

@@ -0,0 +1,75 @@
import type { ToolResponse } from '@/tools/types'
export interface Neo4jConnectionConfig {
host: string
port: number
database: string
username: string
password: string
encryption?: 'enabled' | 'disabled'
}
export interface Neo4jQueryParams extends Neo4jConnectionConfig {
cypherQuery: string
parameters?: Record<string, unknown>
}
export interface Neo4jCreateParams extends Neo4jConnectionConfig {
cypherQuery: string
parameters?: Record<string, unknown>
}
export interface Neo4jMergeParams extends Neo4jConnectionConfig {
cypherQuery: string
parameters?: Record<string, unknown>
}
export interface Neo4jUpdateParams extends Neo4jConnectionConfig {
cypherQuery: string
parameters?: Record<string, unknown>
}
export interface Neo4jDeleteParams extends Neo4jConnectionConfig {
cypherQuery: string
parameters?: Record<string, unknown>
detach?: boolean
}
export interface Neo4jExecuteParams extends Neo4jConnectionConfig {
cypherQuery: string
parameters?: Record<string, unknown>
}
export interface Neo4jBaseResponse extends ToolResponse {
output: {
message: string
records?: unknown[]
recordCount?: number
summary?: {
resultAvailableAfter: number
resultConsumedAfter: number
counters?: {
nodesCreated: number
nodesDeleted: number
relationshipsCreated: number
relationshipsDeleted: number
propertiesSet: number
labelsAdded: number
labelsRemoved: number
indexesAdded: number
indexesRemoved: number
constraintsAdded: number
constraintsRemoved: number
}
}
}
error?: string
}
export interface Neo4jQueryResponse extends Neo4jBaseResponse {}
export interface Neo4jCreateResponse extends Neo4jBaseResponse {}
export interface Neo4jMergeResponse extends Neo4jBaseResponse {}
export interface Neo4jUpdateResponse extends Neo4jBaseResponse {}
export interface Neo4jDeleteResponse extends Neo4jBaseResponse {}
export interface Neo4jExecuteResponse extends Neo4jBaseResponse {}
export interface Neo4jResponse extends Neo4jBaseResponse {}

View File

@@ -0,0 +1,101 @@
import type { Neo4jResponse, Neo4jUpdateParams } from '@/tools/neo4j/types'
import type { ToolConfig } from '@/tools/types'
export const updateTool: ToolConfig<Neo4jUpdateParams, Neo4jResponse> = {
id: 'neo4j_update',
name: 'Neo4j Update',
description:
'Execute SET statements to update properties of existing nodes and relationships in Neo4j',
version: '1.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j server hostname or IP address',
},
port: {
type: 'number',
required: true,
visibility: 'user-only',
description: 'Neo4j server port (default: 7687 for Bolt protocol)',
},
database: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Database name to connect to',
},
username: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Neo4j password',
},
encryption: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Connection encryption mode (enabled, disabled)',
},
cypherQuery: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Cypher query with MATCH and SET statements to update properties',
},
parameters: {
type: 'object',
required: false,
visibility: 'user-or-llm',
description: 'Parameters for the Cypher query as a JSON object',
},
},
request: {
url: '/api/tools/neo4j/update',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
host: params.host,
port: Number(params.port),
database: params.database,
username: params.username,
password: params.password,
encryption: params.encryption || 'disabled',
cypherQuery: params.cypherQuery,
parameters: params.parameters,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Neo4j update operation failed')
}
return {
success: true,
output: {
message: data.message || 'Update operation executed successfully',
summary: data.summary,
},
error: undefined,
}
},
outputs: {
message: { type: 'string', description: 'Operation status message' },
summary: { type: 'json', description: 'Update summary with counters for properties set' },
},
}

View File

@@ -41,6 +41,18 @@ import {
asanaUpdateTaskTool,
} from '@/tools/asana'
import { browserUseRunTaskTool } from '@/tools/browser_use'
import {
calendlyCancelEventTool,
calendlyCreateWebhookTool,
calendlyDeleteWebhookTool,
calendlyGetCurrentUserTool,
calendlyGetEventTypeTool,
calendlyGetScheduledEventTool,
calendlyListEventInviteesTool,
calendlyListEventTypesTool,
calendlyListScheduledEventsTool,
calendlyListWebhooksTool,
} from '@/tools/calendly'
import { clayPopulateTool } from '@/tools/clay'
import {
confluenceCreateCommentTool,
@@ -397,6 +409,14 @@ import {
queryTool as mysqlQueryTool,
updateTool as mysqlUpdateTool,
} from '@/tools/mysql'
import {
createTool as neo4jCreateTool,
deleteTool as neo4jDeleteTool,
executeTool as neo4jExecuteTool,
mergeTool as neo4jMergeTool,
queryTool as neo4jQueryTool,
updateTool as neo4jUpdateTool,
} from '@/tools/neo4j'
import {
notionCreateDatabaseTool,
notionCreatePageTool,
@@ -774,6 +794,16 @@ export const tools: Record<string, ToolConfig> = {
supabase_storage_delete_bucket: supabaseStorageDeleteBucketTool,
supabase_storage_get_public_url: supabaseStorageGetPublicUrlTool,
supabase_storage_create_signed_url: supabaseStorageCreateSignedUrlTool,
calendly_get_current_user: calendlyGetCurrentUserTool,
calendly_list_event_types: calendlyListEventTypesTool,
calendly_get_event_type: calendlyGetEventTypeTool,
calendly_list_scheduled_events: calendlyListScheduledEventsTool,
calendly_get_scheduled_event: calendlyGetScheduledEventTool,
calendly_list_event_invitees: calendlyListEventInviteesTool,
calendly_cancel_event: calendlyCancelEventTool,
calendly_list_webhooks: calendlyListWebhooksTool,
calendly_create_webhook: calendlyCreateWebhookTool,
calendly_delete_webhook: calendlyDeleteWebhookTool,
typeform_responses: typeformResponsesTool,
typeform_files: typeformFilesTool,
typeform_insights: typeformInsightsTool,
@@ -852,6 +882,12 @@ export const tools: Record<string, ToolConfig> = {
mysql_update: mysqlUpdateTool,
mysql_delete: mysqlDeleteTool,
mysql_execute: mysqlExecuteTool,
neo4j_query: neo4jQueryTool,
neo4j_create: neo4jCreateTool,
neo4j_merge: neo4jMergeTool,
neo4j_update: neo4jUpdateTool,
neo4j_delete: neo4jDeleteTool,
neo4j_execute: neo4jExecuteTool,
github_pr: githubPrTool,
github_comment: githubCommentTool,
github_issue_comment: githubIssueCommentTool,

View File

@@ -0,0 +1,4 @@
export { calendlyInviteeCanceledTrigger } from './invitee_canceled'
export { calendlyInviteeCreatedTrigger } from './invitee_created'
export { calendlyRoutingFormSubmittedTrigger } from './routing_form_submitted'
export { calendlyWebhookTrigger } from './webhook'

View File

@@ -0,0 +1,88 @@
import { CalendlyIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
import { buildInviteeOutputs } from './utils'
export const calendlyInviteeCanceledTrigger: TriggerConfig = {
id: 'calendly_invitee_canceled',
name: 'Calendly Invitee Canceled',
provider: 'calendly',
description: 'Trigger workflow when someone cancels a scheduled event on Calendly',
version: '1.0.0',
icon: CalendlyIcon,
subBlocks: [
{
id: 'apiKey',
title: 'Personal Access Token',
type: 'short-input',
placeholder: 'Enter your Calendly personal access token',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_canceled',
},
},
{
id: 'organization',
title: 'Organization URI',
type: 'short-input',
placeholder: 'https://api.calendly.com/organizations/XXXXXX',
description:
'Organization URI for the webhook subscription. Get this from "Get Current User" operation.',
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_canceled',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'This webhook triggers when an invitee cancels an event. The payload includes cancellation details and reason.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_canceled',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_invitee_canceled',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_canceled',
},
},
],
outputs: buildInviteeOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Calendly-Webhook-Signature': 'v1,signature...',
'User-Agent': 'Calendly-Webhook',
},
},
}

View File

@@ -0,0 +1,97 @@
import { CalendlyIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
import { buildInviteeOutputs, calendlyTriggerOptions } from './utils'
export const calendlyInviteeCreatedTrigger: TriggerConfig = {
id: 'calendly_invitee_created',
name: 'Calendly Invitee Created',
provider: 'calendly',
description: 'Trigger workflow when someone schedules a new event on Calendly',
version: '1.0.0',
icon: CalendlyIcon,
subBlocks: [
{
id: 'selectedTriggerId',
title: 'Trigger Type',
type: 'dropdown',
mode: 'trigger',
options: calendlyTriggerOptions,
value: () => 'calendly_invitee_created',
required: true,
},
{
id: 'apiKey',
title: 'Personal Access Token',
type: 'short-input',
placeholder: 'Enter your Calendly personal access token',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_created',
},
},
{
id: 'organization',
title: 'Organization URI',
type: 'short-input',
placeholder: 'https://api.calendly.com/organizations/XXXXXX',
description:
'Organization URI for the webhook subscription. Get this from "Get Current User" operation.',
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'This webhook triggers when an invitee schedules a new event. Rescheduling triggers both cancellation and creation events.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_invitee_created',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_created',
},
},
],
outputs: buildInviteeOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Calendly-Webhook-Signature': 'v1,signature...',
'User-Agent': 'Calendly-Webhook',
},
},
}

View File

@@ -0,0 +1,88 @@
import { CalendlyIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
import { buildRoutingFormOutputs } from './utils'
export const calendlyRoutingFormSubmittedTrigger: TriggerConfig = {
id: 'calendly_routing_form_submitted',
name: 'Calendly Routing Form Submitted',
provider: 'calendly',
description: 'Trigger workflow when someone submits a Calendly routing form',
version: '1.0.0',
icon: CalendlyIcon,
subBlocks: [
{
id: 'apiKey',
title: 'Personal Access Token',
type: 'short-input',
placeholder: 'Enter your Calendly personal access token',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_routing_form_submitted',
},
},
{
id: 'organization',
title: 'Organization URI',
type: 'short-input',
placeholder: 'https://api.calendly.com/organizations/XXXXXX',
description:
'Organization URI for the webhook subscription. Get this from "Get Current User" operation.',
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_routing_form_submitted',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'This webhook triggers when someone submits a routing form, regardless of whether they book an event.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_routing_form_submitted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_routing_form_submitted',
condition: {
field: 'selectedTriggerId',
value: 'calendly_routing_form_submitted',
},
},
],
outputs: buildRoutingFormOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Calendly-Webhook-Signature': 'v1,signature...',
'User-Agent': 'Calendly-Webhook',
},
},
}

View File

@@ -0,0 +1,341 @@
import type { TriggerOutput } from '@/triggers/types'
/**
* Shared trigger dropdown options for all Calendly triggers
*/
export const calendlyTriggerOptions = [
{ label: 'Invitee Created', id: 'calendly_invitee_created' },
{ label: 'Invitee Canceled', id: 'calendly_invitee_canceled' },
{ label: 'Routing Form Submitted', id: 'calendly_routing_form_submitted' },
{ label: 'General Webhook (All Events)', id: 'calendly_webhook' },
]
/**
* Generate setup instructions for a specific Calendly event type
*/
export function calendlySetupInstructions(eventType: string, additionalNotes?: string): string {
const instructions = [
'<strong>Note:</strong> Webhooks require a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'<strong>Important:</strong> Calendly does not provide a UI for creating webhooks. You must create them programmatically using the API.',
'Get your Calendly <strong>Personal Access Token</strong> from the Calendly dashboard under <strong>Integrations > API & Webhooks</strong>.',
'In your workflow, add a Calendly block and select the <strong>"Create Webhook"</strong> operation.',
'Enter your Personal Access Token in the Calendly block.',
'Copy the <strong>Webhook URL</strong> shown above and paste it into the webhook URL field in the Create Webhook operation.',
`Select the event types to monitor. For this trigger, select <strong>${eventType}</strong>.`,
'Set the scope to <strong>Organization</strong> or <strong>User</strong> as needed (routing form submissions require organization scope).',
'Run the workflow to create the webhook subscription. You can use the "List Webhooks" operation to verify it was created.',
]
if (additionalNotes) {
instructions.push(additionalNotes)
}
return instructions
.map(
(instruction, index) =>
`<div class="mb-3">${index === 0 ? instruction : `<strong>${index}.</strong> ${instruction}`}</div>`
)
.join('')
}
/**
* Shared tracking output schema
*/
export const trackingOutputs = {
utm_campaign: {
type: 'string',
description: 'UTM campaign parameter',
},
utm_source: {
type: 'string',
description: 'UTM source parameter',
},
utm_medium: {
type: 'string',
description: 'UTM medium parameter',
},
utm_content: {
type: 'string',
description: 'UTM content parameter',
},
utm_term: {
type: 'string',
description: 'UTM term parameter',
},
salesforce_uuid: {
type: 'string',
description: 'Salesforce UUID',
},
} as const
/**
* Shared questions and answers output schema
*/
export const questionsAndAnswersOutputs = {
type: 'array',
description: 'Questions and answers from the booking form',
items: {
question: {
type: 'string',
description: 'Question text',
},
answer: {
type: 'string',
description: 'Answer text',
},
},
} as const
/**
* Build output schema for invitee events
*/
export function buildInviteeOutputs(): Record<string, TriggerOutput> {
return {
event: {
type: 'string',
description: 'Event type (invitee.created or invitee.canceled)',
},
created_at: {
type: 'string',
description: 'Webhook event creation timestamp',
},
created_by: {
type: 'string',
description: 'URI of the Calendly user who created this webhook',
},
payload: {
uri: {
type: 'string',
description: 'Invitee URI',
},
email: {
type: 'string',
description: 'Invitee email address',
},
name: {
type: 'string',
description: 'Invitee full name',
},
first_name: {
type: 'string',
description: 'Invitee first name',
},
last_name: {
type: 'string',
description: 'Invitee last name',
},
status: {
type: 'string',
description: 'Invitee status (active or canceled)',
},
timezone: {
type: 'string',
description: 'Invitee timezone',
},
event: {
type: 'string',
description: 'Scheduled event URI',
},
questions_and_answers: questionsAndAnswersOutputs,
tracking: trackingOutputs,
text_reminder_number: {
type: 'string',
description: 'Phone number for text reminders',
},
rescheduled: {
type: 'boolean',
description: 'Whether this invitee rescheduled',
},
old_invitee: {
type: 'string',
description: 'URI of the old invitee (if rescheduled)',
},
new_invitee: {
type: 'string',
description: 'URI of the new invitee (if rescheduled)',
},
cancel_url: {
type: 'string',
description: 'URL to cancel the event',
},
reschedule_url: {
type: 'string',
description: 'URL to reschedule the event',
},
created_at: {
type: 'string',
description: 'Invitee creation timestamp',
},
updated_at: {
type: 'string',
description: 'Invitee last update timestamp',
},
canceled: {
type: 'boolean',
description: 'Whether the event was canceled',
},
cancellation: {
type: 'object',
description: 'Cancellation details',
properties: {
canceled_by: {
type: 'string',
description: 'Who canceled the event',
},
reason: {
type: 'string',
description: 'Cancellation reason',
},
},
},
payment: {
type: 'object',
description: 'Payment details',
properties: {
id: {
type: 'string',
description: 'Payment ID',
},
provider: {
type: 'string',
description: 'Payment provider',
},
amount: {
type: 'number',
description: 'Payment amount',
},
currency: {
type: 'string',
description: 'Payment currency',
},
terms: {
type: 'string',
description: 'Payment terms',
},
successful: {
type: 'boolean',
description: 'Whether payment was successful',
},
},
},
no_show: {
type: 'object',
description: 'No-show details',
properties: {
created_at: {
type: 'string',
description: 'No-show marked timestamp',
},
},
},
reconfirmation: {
type: 'object',
description: 'Reconfirmation details',
properties: {
created_at: {
type: 'string',
description: 'Reconfirmation timestamp',
},
confirmed_at: {
type: 'string',
description: 'Confirmation timestamp',
},
},
},
},
} as any
}
/**
* Build output schema for routing form submission events
*/
export function buildRoutingFormOutputs(): Record<string, TriggerOutput> {
return {
event: {
type: 'string',
description: 'Event type (routing_form_submission.created)',
},
created_at: {
type: 'string',
description: 'Webhook event creation timestamp',
},
created_by: {
type: 'string',
description: 'URI of the Calendly user who created this webhook',
},
payload: {
uri: {
type: 'string',
description: 'Routing form submission URI',
},
routing_form: {
type: 'string',
description: 'Routing form URI',
},
submitter: {
type: 'object',
description: 'Submitter details',
properties: {
uri: {
type: 'string',
description: 'Submitter URI',
},
email: {
type: 'string',
description: 'Submitter email address',
},
name: {
type: 'string',
description: 'Submitter full name',
},
},
},
submitter_type: {
type: 'string',
description: 'Type of submitter',
},
questions_and_answers: questionsAndAnswersOutputs,
tracking: trackingOutputs,
result: {
type: 'object',
description: 'Routing result details',
properties: {
type: {
type: 'string',
description: 'Result type (event_type, custom_message, or external_url)',
},
value: {
type: 'string',
description: 'Result value (event type URI, message, or URL)',
},
},
},
created_at: {
type: 'string',
description: 'Submission creation timestamp',
},
updated_at: {
type: 'string',
description: 'Submission last update timestamp',
},
},
} as any
}
/**
* Check if a Calendly event matches the expected trigger configuration
*/
export function isCalendlyEventMatch(triggerId: string, eventType: string): boolean {
const eventMap: Record<string, string> = {
calendly_invitee_created: 'invitee.created',
calendly_invitee_canceled: 'invitee.canceled',
calendly_routing_form_submitted: 'routing_form_submission.created',
}
const expectedEvent = eventMap[triggerId]
if (!expectedEvent) {
return true // Unknown trigger or general webhook, allow through
}
return expectedEvent === eventType
}

View File

@@ -0,0 +1,105 @@
import { CalendlyIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
export const calendlyWebhookTrigger: TriggerConfig = {
id: 'calendly_webhook',
name: 'Calendly Webhook',
provider: 'calendly',
description: 'Trigger workflow from any Calendly webhook event',
version: '1.0.0',
icon: CalendlyIcon,
subBlocks: [
{
id: 'apiKey',
title: 'Personal Access Token',
type: 'short-input',
placeholder: 'Enter your Calendly personal access token',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_webhook',
},
},
{
id: 'organization',
title: 'Organization URI',
type: 'short-input',
placeholder: 'https://api.calendly.com/organizations/XXXXXX',
description:
'Organization URI for the webhook subscription. Get this from "Get Current User" operation.',
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_webhook',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'This webhook subscribes to all Calendly events (invitee created, invitee canceled, and routing form submitted). Use the <code>event</code> field in the payload to determine the event type.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'calendly_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_webhook',
condition: {
field: 'selectedTriggerId',
value: 'calendly_webhook',
},
},
],
outputs: {
event: {
type: 'string',
description:
'Event type (invitee.created, invitee.canceled, or routing_form_submission.created)',
},
created_at: {
type: 'string',
description: 'Webhook event creation timestamp',
},
created_by: {
type: 'string',
description: 'URI of the Calendly user who created this webhook',
},
payload: {
type: 'object',
description: 'Complete event payload (structure varies by event type)',
},
},
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Calendly-Webhook-Signature': 'v1,signature...',
'User-Agent': 'Calendly-Webhook',
},
},
}

View File

@@ -1,4 +1,10 @@
import { airtableWebhookTrigger } from '@/triggers/airtable'
import {
calendlyInviteeCanceledTrigger,
calendlyInviteeCreatedTrigger,
calendlyRoutingFormSubmittedTrigger,
calendlyWebhookTrigger,
} from '@/triggers/calendly'
import { genericWebhookTrigger } from '@/triggers/generic'
import {
githubIssueClosedTrigger,
@@ -83,6 +89,10 @@ import { whatsappWebhookTrigger } from '@/triggers/whatsapp'
export const TRIGGER_REGISTRY: TriggerRegistry = {
slack_webhook: slackWebhookTrigger,
airtable_webhook: airtableWebhookTrigger,
calendly_webhook: calendlyWebhookTrigger,
calendly_invitee_created: calendlyInviteeCreatedTrigger,
calendly_invitee_canceled: calendlyInviteeCanceledTrigger,
calendly_routing_form_submitted: calendlyRoutingFormSubmittedTrigger,
generic_webhook: genericWebhookTrigger,
github_webhook: githubWebhookTrigger,
github_issue_opened: githubIssueOpenedTrigger,

View File

@@ -12,6 +12,7 @@
"cronstrue": "3.3.0",
"drizzle-orm": "^0.44.5",
"mongodb": "6.19.0",
"neo4j-driver": "6.0.1",
"onedollarstats": "0.0.10",
"postgres": "^3.4.5",
"remark-gfm": "4.0.1",
@@ -113,6 +114,7 @@
"entities": "6.0.1",
"framer-motion": "^12.5.0",
"fuse.js": "7.1.0",
"fuzzysort": "3.1.0",
"gray-matter": "^4.0.3",
"groq-sdk": "^0.15.0",
"html-to-text": "^9.0.5",
@@ -1519,7 +1521,7 @@
"bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
@@ -1953,6 +1955,8 @@
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
"fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
"gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="],
"gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="],
@@ -2445,6 +2449,12 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"neo4j-driver": ["neo4j-driver@6.0.1", "", { "dependencies": { "neo4j-driver-bolt-connection": "6.0.1", "neo4j-driver-core": "6.0.1", "rxjs": "^7.8.2" } }, "sha512-8DDF2MwEJNz7y7cp97x4u8fmVIP4CWS8qNBxdwxTG0fWtsS+2NdeC+7uXwmmuFOpHvkfXqv63uWY73bfDtOH8Q=="],
"neo4j-driver-bolt-connection": ["neo4j-driver-bolt-connection@6.0.1", "", { "dependencies": { "buffer": "^6.0.3", "neo4j-driver-core": "6.0.1", "string_decoder": "^1.3.0" } }, "sha512-1KyG73TO+CwnYJisdHD0sjUw9yR+P5q3JFcmVPzsHT4/whzCjuXSMpmY4jZcHH2PdY2cBUq4l/6WcDiPMxW2UA=="],
"neo4j-driver-core": ["neo4j-driver-core@6.0.1", "", {}, "sha512-5I2KxICAvcHxnWdJyDqwu8PBAQvWVTlQH2ve3VQmtVdJScPqWhpXN1PiX5IIl+cRF3pFpz9GQF53B5n6s0QQUQ=="],
"next": ["next@15.4.1", "", { "dependencies": { "@next/env": "15.4.1", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.1", "@next/swc-darwin-x64": "15.4.1", "@next/swc-linux-arm64-gnu": "15.4.1", "@next/swc-linux-arm64-musl": "15.4.1", "@next/swc-linux-x64-gnu": "15.4.1", "@next/swc-linux-x64-musl": "15.4.1", "@next/swc-win32-arm64-msvc": "15.4.1", "@next/swc-win32-x64-msvc": "15.4.1", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-eNKB1q8C7o9zXF8+jgJs2CzSLIU3T6bQtX6DcTnCq1sIR1CJ0GlSyRs1BubQi3/JgCnr9Vr+rS5mOMI38FFyQw=="],
"next-mdx-remote": ["next-mdx-remote@5.0.0", "", { "dependencies": { "@babel/code-frame": "^7.23.5", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", "unist-util-remove": "^3.1.0", "vfile": "^6.0.1", "vfile-matter": "^5.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-RNNbqRpK9/dcIFZs/esQhuLA8jANqlH694yqoDBK8hkVdJUndzzGmnPHa2nyi90N4Z9VmzuSWNRpr5ItT3M7xQ=="],
@@ -2893,7 +2903,7 @@
"string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
@@ -3425,6 +3435,8 @@
"better-auth/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -3577,6 +3589,8 @@
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
"readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"resend/@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="],
@@ -3619,6 +3633,8 @@
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"sucrase/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
@@ -3757,10 +3773,14 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"bl/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"cli-truncate/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"concat-stream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"engine.io/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
@@ -3857,10 +3877,6 @@
"protobufjs/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"readable-web-to-node-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"resend/@react-email/render/prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
@@ -3971,8 +3987,6 @@
"ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"readable-web-to-node-stream/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"sim/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"sim/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],

View File

@@ -42,6 +42,7 @@
"cronstrue": "3.3.0",
"drizzle-orm": "^0.44.5",
"mongodb": "6.19.0",
"neo4j-driver": "6.0.1",
"onedollarstats": "0.0.10",
"postgres": "^3.4.5",
"remark-gfm": "4.0.1",