Compare commits

...

4 Commits

Author SHA1 Message Date
Vikhyath Mondreti
0fb6d9d451 remove comments 2026-01-13 00:38:42 -08:00
Vikhyath Mondreti
77241a1220 fix 2026-01-13 00:36:12 -08:00
Vikhyath Mondreti
a05003a2d3 feat(integrations): claude skills to add integrations, lemlist trigger + tools, remove test webhook url (#2785)
* feat(integrations): claude skills to add integrations, lemlist trigger + tools, remove test webhook url

* fix tests

* fix tools

* add more details to skill

* more details

* address greptile comments
2026-01-12 22:18:50 -08:00
Waleed
46417ddb8c feat(invitations): added FF to disable invitations, added to permission groups, added workspace members admin endpoints (#2783)
* feat(invitations): added FF to disable invitations, added to permission groups, added workspace members admin endpoints

* fix failing tests
2026-01-12 19:33:43 -08:00
69 changed files with 4524 additions and 999 deletions

View File

@@ -0,0 +1,591 @@
---
description: Create a block configuration for a Sim Studio integration with proper subBlocks, conditions, and tool wiring
argument-hint: <service-name>
---
# Add Block Skill
You are an expert at creating block configurations for Sim Studio. You understand the serializer, subBlock types, conditions, dependsOn, modes, and all UI patterns.
## Your Task
When the user asks you to create a block:
1. Create the block file in `apps/sim/blocks/blocks/{service}.ts`
2. Configure all subBlocks with proper types, conditions, and dependencies
3. Wire up tools correctly
## Block Configuration Structure
```typescript
import { {ServiceName}Icon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const {ServiceName}Block: BlockConfig = {
type: '{service}', // snake_case identifier
name: '{Service Name}', // Human readable
description: 'Brief description', // One sentence
longDescription: 'Detailed description for docs',
docsLink: 'https://docs.sim.ai/tools/{service}',
category: 'tools', // 'tools' | 'blocks' | 'triggers'
bgColor: '#HEXCOLOR', // Brand color
icon: {ServiceName}Icon,
// Auth mode
authMode: AuthMode.OAuth, // or AuthMode.ApiKey
subBlocks: [
// Define all UI fields here
],
tools: {
access: ['tool_id_1', 'tool_id_2'], // Array of tool IDs this block can use
config: {
tool: (params) => `{service}_${params.operation}`, // Tool selector function
params: (params) => ({
// Transform subBlock values to tool params
}),
},
},
inputs: {
// Optional: define expected inputs from other blocks
},
outputs: {
// Define outputs available to downstream blocks
},
}
```
## SubBlock Types Reference
**Critical:** Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions.
### Text Inputs
```typescript
// Single-line input
{ id: 'field', title: 'Label', type: 'short-input', placeholder: '...' }
// Multi-line input
{ id: 'field', title: 'Label', type: 'long-input', placeholder: '...', rows: 6 }
// Password input
{ id: 'apiKey', title: 'API Key', type: 'short-input', password: true }
```
### Selection Inputs
```typescript
// Dropdown (static options)
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Create', id: 'create' },
{ label: 'Update', id: 'update' },
],
value: () => 'create', // Default value function
}
// Combobox (searchable dropdown)
{
id: 'field',
title: 'Label',
type: 'combobox',
options: [...],
searchable: true,
}
```
### Code/JSON Inputs
```typescript
{
id: 'code',
title: 'Code',
type: 'code',
language: 'javascript', // 'javascript' | 'json' | 'python'
placeholder: '// Enter code...',
}
```
### OAuth/Credentials
```typescript
{
id: 'credential',
title: 'Account',
type: 'oauth-input',
serviceId: '{service}', // Must match OAuth provider
placeholder: 'Select account',
required: true,
}
```
### Selectors (with dynamic options)
```typescript
// Channel selector (Slack, Discord, etc.)
{
id: 'channel',
title: 'Channel',
type: 'channel-selector',
serviceId: '{service}',
placeholder: 'Select channel',
dependsOn: ['credential'],
}
// Project selector (Jira, etc.)
{
id: 'project',
title: 'Project',
type: 'project-selector',
serviceId: '{service}',
dependsOn: ['credential'],
}
// File selector (Google Drive, etc.)
{
id: 'file',
title: 'File',
type: 'file-selector',
serviceId: '{service}',
mimeType: 'application/pdf',
dependsOn: ['credential'],
}
// User selector
{
id: 'user',
title: 'User',
type: 'user-selector',
serviceId: '{service}',
dependsOn: ['credential'],
}
```
### Other Types
```typescript
// Switch/toggle
{ id: 'enabled', type: 'switch' }
// Slider
{ id: 'temperature', title: 'Temperature', type: 'slider', min: 0, max: 2, step: 0.1 }
// Table (key-value pairs)
{ id: 'headers', title: 'Headers', type: 'table', columns: ['Key', 'Value'] }
// File upload
{
id: 'files',
title: 'Attachments',
type: 'file-upload',
multiple: true,
acceptedTypes: 'image/*,application/pdf',
}
```
## Condition Syntax
Controls when a field is shown based on other field values.
### Simple Condition
```typescript
condition: { field: 'operation', value: 'create' }
// Shows when operation === 'create'
```
### Multiple Values (OR)
```typescript
condition: { field: 'operation', value: ['create', 'update'] }
// Shows when operation is 'create' OR 'update'
```
### Negation
```typescript
condition: { field: 'operation', value: 'delete', not: true }
// Shows when operation !== 'delete'
```
### Compound (AND)
```typescript
condition: {
field: 'operation',
value: 'send',
and: {
field: 'type',
value: 'dm',
not: true,
}
}
// Shows when operation === 'send' AND type !== 'dm'
```
### Complex Example
```typescript
condition: {
field: 'operation',
value: ['list', 'search'],
not: true,
and: {
field: 'authMethod',
value: 'oauth',
}
}
// Shows when operation NOT in ['list', 'search'] AND authMethod === 'oauth'
```
## DependsOn Pattern
Controls when a field is enabled and when its options are refetched.
### Simple Array (all must be set)
```typescript
dependsOn: ['credential']
// Enabled only when credential has a value
// Options refetch when credential changes
dependsOn: ['credential', 'projectId']
// Enabled only when BOTH have values
```
### Complex (all + any)
```typescript
dependsOn: {
all: ['authMethod'], // All must be set
any: ['credential', 'apiKey'] // At least one must be set
}
// Enabled when authMethod is set AND (credential OR apiKey is set)
```
## Required Pattern
Can be boolean or condition-based.
### Simple Boolean
```typescript
required: true
required: false
```
### Conditional Required
```typescript
required: { field: 'operation', value: 'create' }
// Required only when operation === 'create'
required: { field: 'operation', value: ['create', 'update'] }
// Required when operation is 'create' OR 'update'
```
## Mode Pattern (Basic vs Advanced)
Controls which UI view shows the field.
### Mode Options
- `'basic'` - Only in basic view (default UI)
- `'advanced'` - Only in advanced view
- `'both'` - Both views (default if not specified)
- `'trigger'` - Only in trigger configuration
### canonicalParamId Pattern
Maps multiple UI fields to a single serialized parameter:
```typescript
// Basic mode: Visual selector
{
id: 'channel',
title: 'Channel',
type: 'channel-selector',
mode: 'basic',
canonicalParamId: 'channel', // Both map to 'channel' param
dependsOn: ['credential'],
}
// Advanced mode: Manual input
{
id: 'channelId',
title: 'Channel ID',
type: 'short-input',
mode: 'advanced',
canonicalParamId: 'channel', // Both map to 'channel' param
placeholder: 'Enter channel ID manually',
}
```
**How it works:**
- In basic mode: `channel` selector value → `params.channel`
- In advanced mode: `channelId` input value → `params.channel`
- The serializer consolidates based on current mode
**Critical constraints:**
- `canonicalParamId` must NOT match any other subblock's `id` in the same block (causes conflicts)
- `canonicalParamId` must be unique per block (only one basic/advanced pair per canonicalParamId)
- ONLY use `canonicalParamId` to link basic/advanced mode alternatives for the same logical parameter
- Do NOT use it for any other purpose
## WandConfig Pattern
Enables AI-assisted field generation.
```typescript
{
id: 'query',
title: 'Query',
type: 'code',
language: 'json',
wandConfig: {
enabled: true,
prompt: 'Generate a query based on the user request. Return ONLY the JSON.',
placeholder: 'Describe what you want to query...',
generationType: 'json-object', // Optional: affects AI behavior
maintainHistory: true, // Optional: keeps conversation context
},
}
```
### Generation Types
- `'javascript-function-body'` - JS code generation
- `'json-object'` - Raw JSON (adds "no markdown" instruction)
- `'json-schema'` - JSON Schema definitions
- `'sql-query'` - SQL statements
- `'timestamp'` - Adds current date/time context
## Tools Configuration
### Simple Tool Selector
```typescript
tools: {
access: ['service_create', 'service_read', 'service_update'],
config: {
tool: (params) => `service_${params.operation}`,
},
}
```
### With Parameter Transformation
```typescript
tools: {
access: ['service_action'],
config: {
tool: (params) => 'service_action',
params: (params) => ({
id: params.resourceId,
data: typeof params.data === 'string' ? JSON.parse(params.data) : params.data,
}),
},
}
```
### V2 Versioned Tool Selector
```typescript
import { createVersionedToolSelector } from '@/blocks/utils'
tools: {
access: [
'service_create_v2',
'service_read_v2',
'service_update_v2',
],
config: {
tool: createVersionedToolSelector({
baseToolSelector: (params) => `service_${params.operation}`,
suffix: '_v2',
fallbackToolId: 'service_create_v2',
}),
},
}
```
## Outputs Definition
**IMPORTANT:** Block outputs have a simpler schema than tool outputs. Block outputs do NOT support:
- `optional: true` - This is only for tool outputs
- `items` property - This is only for tool outputs with array types
Block outputs only support:
- `type` - The data type ('string', 'number', 'boolean', 'json', 'array')
- `description` - Human readable description
- Nested object structure (for complex types)
```typescript
outputs: {
// Simple outputs
id: { type: 'string', description: 'Resource ID' },
success: { type: 'boolean', description: 'Whether operation succeeded' },
// Use type: 'json' for complex objects or arrays (NOT type: 'array' with items)
items: { type: 'json', description: 'List of items' },
metadata: { type: 'json', description: 'Response metadata' },
// Nested outputs (for structured data)
user: {
id: { type: 'string', description: 'User ID' },
name: { type: 'string', description: 'User name' },
email: { type: 'string', description: 'User email' },
},
}
```
## V2 Block Pattern
When creating V2 blocks (alongside legacy V1):
```typescript
// V1 Block - mark as legacy
export const ServiceBlock: BlockConfig = {
type: 'service',
name: 'Service (Legacy)',
hideFromToolbar: true, // Hide from toolbar
// ... rest of config
}
// V2 Block - visible, uses V2 tools
export const ServiceV2Block: BlockConfig = {
type: 'service_v2',
name: 'Service', // Clean name
hideFromToolbar: false, // Visible
subBlocks: ServiceBlock.subBlocks, // Reuse UI
tools: {
access: ServiceBlock.tools?.access?.map(id => `${id}_v2`) || [],
config: {
tool: createVersionedToolSelector({
baseToolSelector: (params) => (ServiceBlock.tools?.config as any)?.tool(params),
suffix: '_v2',
fallbackToolId: 'service_default_v2',
}),
params: ServiceBlock.tools?.config?.params,
},
},
outputs: {
// Flat, API-aligned outputs (not wrapped in content/metadata)
},
}
```
## Registering Blocks
After creating the block, remind the user to:
1. Import in `apps/sim/blocks/registry.ts`
2. Add to the `registry` object (alphabetically):
```typescript
import { ServiceBlock } from '@/blocks/blocks/service'
export const registry: Record<string, BlockConfig> = {
// ... existing blocks ...
service: ServiceBlock,
}
```
## Complete Example
```typescript
import { ServiceIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const ServiceBlock: BlockConfig = {
type: 'service',
name: 'Service',
description: 'Integrate with Service API',
longDescription: 'Full description for documentation...',
docsLink: 'https://docs.sim.ai/tools/service',
category: 'tools',
bgColor: '#FF6B6B',
icon: ServiceIcon,
authMode: AuthMode.OAuth,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Create', id: 'create' },
{ label: 'Read', id: 'read' },
{ label: 'Update', id: 'update' },
{ label: 'Delete', id: 'delete' },
],
value: () => 'create',
},
{
id: 'credential',
title: 'Service Account',
type: 'oauth-input',
serviceId: 'service',
placeholder: 'Select account',
required: true,
},
{
id: 'resourceId',
title: 'Resource ID',
type: 'short-input',
placeholder: 'Enter resource ID',
condition: { field: 'operation', value: ['read', 'update', 'delete'] },
required: { field: 'operation', value: ['read', 'update', 'delete'] },
},
{
id: 'name',
title: 'Name',
type: 'short-input',
placeholder: 'Resource name',
condition: { field: 'operation', value: ['create', 'update'] },
required: { field: 'operation', value: 'create' },
},
],
tools: {
access: ['service_create', 'service_read', 'service_update', 'service_delete'],
config: {
tool: (params) => `service_${params.operation}`,
},
},
outputs: {
id: { type: 'string', description: 'Resource ID' },
name: { type: 'string', description: 'Resource name' },
createdAt: { type: 'string', description: 'Creation timestamp' },
},
}
```
## Connecting Blocks with Triggers
If the service supports webhooks, connect the block to its triggers.
```typescript
import { getTrigger } from '@/triggers'
export const ServiceBlock: BlockConfig = {
// ... basic config ...
triggers: {
enabled: true,
available: ['service_event_a', 'service_event_b', 'service_webhook'],
},
subBlocks: [
// Tool subBlocks first...
{ id: 'operation', /* ... */ },
// Then spread trigger subBlocks
...getTrigger('service_event_a').subBlocks,
...getTrigger('service_event_b').subBlocks,
...getTrigger('service_webhook').subBlocks,
],
}
```
See the `/add-trigger` skill for creating triggers.
## Checklist Before Finishing
- [ ] All subBlocks have `id`, `title` (except switch), and `type`
- [ ] Conditions use correct syntax (field, value, not, and)
- [ ] DependsOn set for fields that need other values
- [ ] Required fields marked correctly (boolean or condition)
- [ ] OAuth inputs have correct `serviceId`
- [ ] Tools.access lists all tool IDs
- [ ] Tools.config.tool returns correct tool ID
- [ ] Outputs match tool outputs
- [ ] Block registered in registry.ts
- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread

View File

@@ -0,0 +1,450 @@
---
description: Add a complete integration to Sim Studio (tools, block, icon, registration)
argument-hint: <service-name> [api-docs-url]
---
# Add Integration Skill
You are an expert at adding complete integrations to Sim Studio. This skill orchestrates the full process of adding a new service integration.
## Overview
Adding an integration involves these steps in order:
1. **Research** - Read the service's API documentation
2. **Create Tools** - Build tool configurations for each API operation
3. **Create Block** - Build the block UI configuration
4. **Add Icon** - Add the service's brand icon
5. **Create Triggers** (optional) - If the service supports webhooks
6. **Register** - Register tools, block, and triggers in their registries
7. **Generate Docs** - Run the docs generation script
## Step 1: Research the API
Before writing any code:
1. Use Context7 to find official documentation: `mcp__plugin_context7_context7__resolve-library-id`
2. Or use WebFetch to read API docs directly
3. Identify:
- Authentication method (OAuth, API Key, both)
- Available operations (CRUD, search, etc.)
- Required vs optional parameters
- Response structures
## Step 2: Create Tools
### Directory Structure
```
apps/sim/tools/{service}/
├── index.ts # Barrel exports
├── types.ts # TypeScript interfaces
├── {action1}.ts # Tool for action 1
├── {action2}.ts # Tool for action 2
└── ...
```
### Key Patterns
**types.ts:**
```typescript
import type { ToolResponse } from '@/tools/types'
export interface {Service}{Action}Params {
accessToken: string // For OAuth services
// OR
apiKey: string // For API key services
requiredParam: string
optionalParam?: string
}
export interface {Service}Response extends ToolResponse {
output: {
// Define output structure
}
}
```
**Tool file pattern:**
```typescript
export const {service}{Action}Tool: ToolConfig<Params, Response> = {
id: '{service}_{action}',
name: '{Service} {Action}',
description: '...',
version: '1.0.0',
oauth: { required: true, provider: '{service}' }, // If OAuth
params: {
accessToken: { type: 'string', required: true, visibility: 'hidden', description: '...' },
// ... other params
},
request: { url, method, headers, body },
transformResponse: async (response) => {
const data = await response.json()
return {
success: true,
output: {
field: data.field ?? null, // Always handle nullables
},
}
},
outputs: { /* ... */ },
}
```
### Critical Rules
- `visibility: 'hidden'` for OAuth tokens
- `visibility: 'user-only'` for API keys and user credentials
- `visibility: 'user-or-llm'` for operation parameters
- Always use `?? null` for nullable API response fields
- Always use `?? []` for optional array fields
- Set `optional: true` for outputs that may not exist
- Never output raw JSON dumps - extract meaningful fields
## Step 3: Create Block
### File Location
`apps/sim/blocks/blocks/{service}.ts`
### Block Structure
```typescript
import { {Service}Icon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const {Service}Block: BlockConfig = {
type: '{service}',
name: '{Service}',
description: '...',
longDescription: '...',
docsLink: 'https://docs.sim.ai/tools/{service}',
category: 'tools',
bgColor: '#HEXCOLOR',
icon: {Service}Icon,
authMode: AuthMode.OAuth, // or AuthMode.ApiKey
subBlocks: [
// Operation dropdown
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Operation 1', id: 'action1' },
{ label: 'Operation 2', id: 'action2' },
],
value: () => 'action1',
},
// Credential field
{
id: 'credential',
title: '{Service} Account',
type: 'oauth-input',
serviceId: '{service}',
required: true,
},
// Conditional fields per operation
// ...
],
tools: {
access: ['{service}_action1', '{service}_action2'],
config: {
tool: (params) => `{service}_${params.operation}`,
},
},
outputs: { /* ... */ },
}
```
### Key SubBlock Patterns
**Condition-based visibility:**
```typescript
{
id: 'resourceId',
title: 'Resource ID',
type: 'short-input',
condition: { field: 'operation', value: ['read', 'update', 'delete'] },
required: { field: 'operation', value: ['read', 'update', 'delete'] },
}
```
**DependsOn for cascading selectors:**
```typescript
{
id: 'project',
type: 'project-selector',
dependsOn: ['credential'],
},
{
id: 'issue',
type: 'file-selector',
dependsOn: ['credential', 'project'],
}
```
**Basic/Advanced mode for dual UX:**
```typescript
// Basic: Visual selector
{
id: 'channel',
type: 'channel-selector',
mode: 'basic',
canonicalParamId: 'channel',
dependsOn: ['credential'],
},
// Advanced: Manual input
{
id: 'channelId',
type: 'short-input',
mode: 'advanced',
canonicalParamId: 'channel',
}
```
**Critical:**
- `canonicalParamId` must NOT match any other subblock's `id`, must be unique per block, and should only be used to link basic/advanced alternatives for the same parameter.
- `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent.
- Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions.
## Step 4: Add Icon
### File Location
`apps/sim/components/icons.tsx`
### Pattern
```typescript
export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* SVG paths from brand assets */}
</svg>
)
}
```
### Finding Icons
1. Check the service's brand/press kit page
2. Download SVG logo
3. Convert to React component
4. Ensure it accepts and spreads props
## Step 5: Create Triggers (Optional)
If the service supports webhooks, create triggers using the generic `buildTriggerSubBlocks` helper.
### Directory Structure
```
apps/sim/triggers/{service}/
├── index.ts # Barrel exports
├── utils.ts # Trigger options, setup instructions, extra fields
├── {event_a}.ts # Primary trigger (includes dropdown)
├── {event_b}.ts # Secondary triggers (no dropdown)
└── webhook.ts # Generic webhook (optional)
```
### Key Pattern
```typescript
import { buildTriggerSubBlocks } from '@/triggers'
import { {service}TriggerOptions, {service}SetupInstructions, build{Service}ExtraFields } from './utils'
// Primary trigger - includeDropdown: true
export const {service}EventATrigger: TriggerConfig = {
id: '{service}_event_a',
subBlocks: buildTriggerSubBlocks({
triggerId: '{service}_event_a',
triggerOptions: {service}TriggerOptions,
includeDropdown: true, // Only for primary trigger!
setupInstructions: {service}SetupInstructions('Event A'),
extraFields: build{Service}ExtraFields('{service}_event_a'),
}),
// ...
}
// Secondary triggers - no dropdown
export const {service}EventBTrigger: TriggerConfig = {
id: '{service}_event_b',
subBlocks: buildTriggerSubBlocks({
triggerId: '{service}_event_b',
triggerOptions: {service}TriggerOptions,
// No includeDropdown!
setupInstructions: {service}SetupInstructions('Event B'),
extraFields: build{Service}ExtraFields('{service}_event_b'),
}),
// ...
}
```
### Connect to Block
```typescript
import { getTrigger } from '@/triggers'
export const {Service}Block: BlockConfig = {
triggers: {
enabled: true,
available: ['{service}_event_a', '{service}_event_b'],
},
subBlocks: [
// Tool fields...
...getTrigger('{service}_event_a').subBlocks,
...getTrigger('{service}_event_b').subBlocks,
],
}
```
See `/add-trigger` skill for complete documentation.
## Step 6: Register Everything
### Tools Registry (`apps/sim/tools/registry.ts`)
```typescript
// Add import (alphabetically)
import {
{service}Action1Tool,
{service}Action2Tool,
} from '@/tools/{service}'
// Add to tools object (alphabetically)
export const tools: Record<string, ToolConfig> = {
// ... existing tools ...
{service}_action1: {service}Action1Tool,
{service}_action2: {service}Action2Tool,
}
```
### Block Registry (`apps/sim/blocks/registry.ts`)
```typescript
// Add import (alphabetically)
import { {Service}Block } from '@/blocks/blocks/{service}'
// Add to registry (alphabetically)
export const registry: Record<string, BlockConfig> = {
// ... existing blocks ...
{service}: {Service}Block,
}
```
### Trigger Registry (`apps/sim/triggers/registry.ts`) - If triggers exist
```typescript
// Add import (alphabetically)
import {
{service}EventATrigger,
{service}EventBTrigger,
{service}WebhookTrigger,
} from '@/triggers/{service}'
// Add to TRIGGER_REGISTRY (alphabetically)
export const TRIGGER_REGISTRY: TriggerRegistry = {
// ... existing triggers ...
{service}_event_a: {service}EventATrigger,
{service}_event_b: {service}EventBTrigger,
{service}_webhook: {service}WebhookTrigger,
}
```
## Step 7: Generate Docs
Run the documentation generator:
```bash
bun run scripts/generate-docs.ts
```
This creates `apps/docs/content/docs/en/tools/{service}.mdx`
## V2 Integration Pattern
If creating V2 versions (API-aligned outputs):
1. **V2 Tools** - Add `_v2` suffix, version `2.0.0`, flat outputs
2. **V2 Block** - Add `_v2` type, use `createVersionedToolSelector`
3. **V1 Block** - Add `(Legacy)` to name, set `hideFromToolbar: true`
4. **Registry** - Register both versions
```typescript
// In registry
{service}: {Service}Block, // V1 (legacy, hidden)
{service}_v2: {Service}V2Block, // V2 (visible)
```
## Complete Checklist
### Tools
- [ ] Created `tools/{service}/` directory
- [ ] Created `types.ts` with all interfaces
- [ ] Created tool file for each operation
- [ ] All params have correct visibility
- [ ] All nullable fields use `?? null`
- [ ] All optional outputs have `optional: true`
- [ ] Created `index.ts` barrel export
- [ ] Registered all tools in `tools/registry.ts`
### Block
- [ ] Created `blocks/blocks/{service}.ts`
- [ ] Defined operation dropdown with all operations
- [ ] Added credential field (oauth-input or short-input)
- [ ] Added conditional fields per operation
- [ ] Set up dependsOn for cascading selectors
- [ ] Configured tools.access with all tool IDs
- [ ] Configured tools.config.tool selector
- [ ] Defined outputs matching tool outputs
- [ ] Registered block in `blocks/registry.ts`
- [ ] If triggers: set `triggers.enabled` and `triggers.available`
- [ ] If triggers: spread trigger subBlocks with `getTrigger()`
### Icon
- [ ] Added icon to `components/icons.tsx`
- [ ] Icon spreads props correctly
### Triggers (if service supports webhooks)
- [ ] Created `triggers/{service}/` directory
- [ ] Created `utils.ts` with options, instructions, and extra fields helpers
- [ ] Primary trigger uses `includeDropdown: true`
- [ ] Secondary triggers do NOT have `includeDropdown`
- [ ] All triggers use `buildTriggerSubBlocks` helper
- [ ] Created `index.ts` barrel export
- [ ] Registered all triggers in `triggers/registry.ts`
### Docs
- [ ] Ran `bun run scripts/generate-docs.ts`
- [ ] Verified docs file created
## Example Command
When the user asks to add an integration:
```
User: Add a Stripe integration
You: I'll add the Stripe integration. Let me:
1. First, research the Stripe API using Context7
2. Create the tools for key operations (payments, subscriptions, etc.)
3. Create the block with operation dropdown
4. Add the Stripe icon
5. Register everything
6. Generate docs
[Proceed with implementation...]
```
## Common Gotchas
1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration
2. **Tool IDs are snake_case** - `stripe_create_payment`, not `stripeCreatePayment`
3. **Block type is snake_case** - `type: 'stripe'`, not `type: 'Stripe'`
4. **Alphabetical ordering** - Keep imports and registry entries alphabetically sorted
5. **Required can be conditional** - Use `required: { field: 'op', value: 'create' }` instead of always true
6. **DependsOn clears options** - When a dependency changes, selector options are refetched

View File

@@ -0,0 +1,284 @@
---
description: Create tool configurations for a Sim Studio integration by reading API docs
argument-hint: <service-name> [api-docs-url]
---
# Add Tools Skill
You are an expert at creating tool configurations for Sim Studio integrations. Your job is to read API documentation and create properly structured tool files.
## Your Task
When the user asks you to create tools for a service:
1. Use Context7 or WebFetch to read the service's API documentation
2. Create the tools directory structure
3. Generate properly typed tool configurations
## Directory Structure
Create files in `apps/sim/tools/{service}/`:
```
tools/{service}/
├── index.ts # Barrel export
├── types.ts # Parameter & response types
└── {action}.ts # Individual tool files (one per operation)
```
## Tool Configuration Structure
Every tool MUST follow this exact structure:
```typescript
import type { {ServiceName}{Action}Params } from '@/tools/{service}/types'
import type { ToolConfig } from '@/tools/types'
interface {ServiceName}{Action}Response {
success: boolean
output: {
// Define output structure here
}
}
export const {serviceName}{Action}Tool: ToolConfig<
{ServiceName}{Action}Params,
{ServiceName}{Action}Response
> = {
id: '{service}_{action}', // snake_case, matches tool name
name: '{Service} {Action}', // Human readable
description: 'Brief description', // One sentence
version: '1.0.0',
// OAuth config (if service uses OAuth)
oauth: {
required: true,
provider: '{service}', // Must match OAuth provider ID
},
params: {
// Hidden params (system-injected)
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token',
},
// User-only params (credentials, IDs user must provide)
someId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'The ID of the resource',
},
// User-or-LLM params (can be provided by user OR computed by LLM)
query: {
type: 'string',
required: false, // Use false for optional
visibility: 'user-or-llm',
description: 'Search query',
},
},
request: {
url: (params) => `https://api.service.com/v1/resource/${params.id}`,
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => ({
// Request body - only for POST/PUT/PATCH
// Trim ID fields to prevent copy-paste whitespace errors:
// userId: params.userId?.trim(),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
// Map API response to output
// Use ?? null for nullable fields
// Use ?? [] for optional arrays
},
}
},
outputs: {
// Define each output field
},
}
```
## Critical Rules for Parameters
### Visibility Options
- `'hidden'` - System-injected (OAuth tokens, internal params). User never sees.
- `'user-only'` - User must provide (credentials, account-specific IDs)
- `'user-or-llm'` - User provides OR LLM can compute (search queries, content, filters)
### Parameter Types
- `'string'` - Text values
- `'number'` - Numeric values
- `'boolean'` - True/false
- `'json'` - Complex objects (NOT 'object', use 'json')
- `'file'` - Single file
- `'file[]'` - Multiple files
### Required vs Optional
- Always explicitly set `required: true` or `required: false`
- Optional params should have `required: false`
## Critical Rules for Outputs
### Output Types
- `'string'`, `'number'`, `'boolean'` - Primitives
- `'json'` - Complex objects (use this, NOT 'object')
- `'array'` - Arrays with `items` property
- `'object'` - Objects with `properties` property
### Optional Outputs
Add `optional: true` for fields that may not exist in the response:
```typescript
closedAt: {
type: 'string',
description: 'When the issue was closed',
optional: true,
},
```
### Nested Properties
For complex outputs, define nested structure:
```typescript
metadata: {
type: 'json',
description: 'Response metadata',
properties: {
id: { type: 'string', description: 'Unique ID' },
status: { type: 'string', description: 'Current status' },
count: { type: 'number', description: 'Total count' },
},
},
items: {
type: 'array',
description: 'List of items',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Item ID' },
name: { type: 'string', description: 'Item name' },
},
},
},
```
## Critical Rules for transformResponse
### Handle Nullable Fields
ALWAYS use `?? null` for fields that may be undefined:
```typescript
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id,
title: data.title,
body: data.body ?? null, // May be undefined
assignee: data.assignee ?? null, // May be undefined
labels: data.labels ?? [], // Default to empty array
closedAt: data.closed_at ?? null, // May be undefined
},
}
}
```
### Never Output Raw JSON Dumps
DON'T do this:
```typescript
output: {
data: data, // BAD - raw JSON dump
}
```
DO this instead - extract meaningful fields:
```typescript
output: {
id: data.id,
name: data.name,
status: data.status,
metadata: {
createdAt: data.created_at,
updatedAt: data.updated_at,
},
}
```
## Types File Pattern
Create `types.ts` with interfaces for all params and responses:
```typescript
import type { ToolResponse } from '@/tools/types'
// Parameter interfaces
export interface {Service}{Action}Params {
accessToken: string
requiredField: string
optionalField?: string
}
// Response interfaces (extend ToolResponse)
export interface {Service}{Action}Response extends ToolResponse {
output: {
field1: string
field2: number
optionalField?: string | null
}
}
```
## Index.ts Barrel Export Pattern
```typescript
// Export all tools
export { serviceTool1 } from './{action1}'
export { serviceTool2 } from './{action2}'
// Export types
export * from './types'
```
## Registering Tools
After creating tools, remind the user to:
1. Import tools in `apps/sim/tools/registry.ts`
2. Add to the `tools` object with snake_case keys:
```typescript
import { serviceActionTool } from '@/tools/{service}'
export const tools = {
// ... existing tools ...
{service}_{action}: serviceActionTool,
}
```
## V2 Tool Pattern
If creating V2 tools (API-aligned outputs), use `_v2` suffix:
- Tool ID: `{service}_{action}_v2`
- Variable name: `{action}V2Tool`
- Version: `'2.0.0'`
- Outputs: Flat, API-aligned (no content/metadata wrapper)
## Checklist Before Finishing
- [ ] All params have explicit `required: true` or `required: false`
- [ ] All params have appropriate `visibility`
- [ ] All nullable response fields use `?? null`
- [ ] All optional outputs have `optional: true`
- [ ] No raw JSON dumps in outputs
- [ ] Types file has all interfaces
- [ ] Index.ts exports all tools
- [ ] Tool IDs use snake_case

View File

@@ -0,0 +1,656 @@
---
description: Create webhook triggers for a Sim Studio integration using the generic trigger builder
argument-hint: <service-name>
---
# Add Trigger Skill
You are an expert at creating webhook triggers for Sim Studio. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks.
## Your Task
When the user asks you to create triggers for a service:
1. Research what webhook events the service supports
2. Create the trigger files using the generic builder
3. Register triggers and connect them to the block
## Directory Structure
```
apps/sim/triggers/{service}/
├── index.ts # Barrel exports
├── utils.ts # Service-specific helpers (trigger options, setup instructions, extra fields)
├── {event_a}.ts # Primary trigger (includes dropdown)
├── {event_b}.ts # Secondary trigger (no dropdown)
├── {event_c}.ts # Secondary trigger (no dropdown)
└── webhook.ts # Generic webhook trigger (optional, for "all events")
```
## Step 1: Create utils.ts
This file contains service-specific helpers used by all triggers.
```typescript
import type { SubBlockConfig } from '@/blocks/types'
import type { TriggerOutput } from '@/triggers/types'
/**
* Dropdown options for the trigger type selector.
* These appear in the primary trigger's dropdown.
*/
export const {service}TriggerOptions = [
{ label: 'Event A', id: '{service}_event_a' },
{ label: 'Event B', id: '{service}_event_b' },
{ label: 'Event C', id: '{service}_event_c' },
{ label: 'Generic Webhook (All Events)', id: '{service}_webhook' },
]
/**
* Generates HTML setup instructions for the trigger.
* Displayed to users to help them configure webhooks in the external service.
*/
export function {service}SetupInstructions(eventType: string): string {
const instructions = [
'Copy the <strong>Webhook URL</strong> above',
'Go to <strong>{Service} Settings > Webhooks</strong>',
'Click <strong>Add Webhook</strong>',
'Paste the webhook URL',
`Select the <strong>${eventType}</strong> event type`,
'Save the webhook configuration',
'Click "Save" above to activate your trigger',
]
return instructions
.map((instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join('')
}
/**
* Service-specific extra fields to add to triggers.
* These are inserted between webhookUrl and triggerSave.
*/
export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
return [
{
id: 'projectId',
title: 'Project ID (Optional)',
type: 'short-input',
placeholder: 'Leave empty for all projects',
description: 'Optionally filter to a specific project',
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
},
]
}
/**
* Build outputs for this trigger type.
* Outputs define what data is available to downstream blocks.
*/
export function build{Service}Outputs(): Record<string, TriggerOutput> {
return {
eventType: { type: 'string', description: 'The type of event that triggered this workflow' },
resourceId: { type: 'string', description: 'ID of the affected resource' },
timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' },
// Nested outputs for complex data
resource: {
id: { type: 'string', description: 'Resource ID' },
name: { type: 'string', description: 'Resource name' },
status: { type: 'string', description: 'Current status' },
},
webhook: { type: 'json', description: 'Full webhook payload' },
}
}
```
## Step 2: Create the Primary Trigger
The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types.
```typescript
import { {Service}Icon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
build{Service}ExtraFields,
build{Service}Outputs,
{service}SetupInstructions,
{service}TriggerOptions,
} from '@/triggers/{service}/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* {Service} Event A Trigger
*
* This is the PRIMARY trigger - it includes the dropdown for selecting trigger type.
*/
export const {service}EventATrigger: TriggerConfig = {
id: '{service}_event_a',
name: '{Service} Event A',
provider: '{service}',
description: 'Trigger workflow when Event A occurs',
version: '1.0.0',
icon: {Service}Icon,
subBlocks: buildTriggerSubBlocks({
triggerId: '{service}_event_a',
triggerOptions: {service}TriggerOptions,
includeDropdown: true, // PRIMARY TRIGGER - includes dropdown
setupInstructions: {service}SetupInstructions('Event A'),
extraFields: build{Service}ExtraFields('{service}_event_a'),
}),
outputs: build{Service}Outputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}
```
## Step 3: Create Secondary Triggers
Secondary triggers do NOT include the dropdown (it's already in the primary trigger).
```typescript
import { {Service}Icon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
build{Service}ExtraFields,
build{Service}Outputs,
{service}SetupInstructions,
{service}TriggerOptions,
} from '@/triggers/{service}/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* {Service} Event B Trigger
*/
export const {service}EventBTrigger: TriggerConfig = {
id: '{service}_event_b',
name: '{Service} Event B',
provider: '{service}',
description: 'Trigger workflow when Event B occurs',
version: '1.0.0',
icon: {Service}Icon,
subBlocks: buildTriggerSubBlocks({
triggerId: '{service}_event_b',
triggerOptions: {service}TriggerOptions,
// NO includeDropdown - secondary trigger
setupInstructions: {service}SetupInstructions('Event B'),
extraFields: build{Service}ExtraFields('{service}_event_b'),
}),
outputs: build{Service}Outputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}
```
## Step 4: Create index.ts Barrel Export
```typescript
export { {service}EventATrigger } from './event_a'
export { {service}EventBTrigger } from './event_b'
export { {service}EventCTrigger } from './event_c'
export { {service}WebhookTrigger } from './webhook'
```
## Step 5: Register Triggers
### Trigger Registry (`apps/sim/triggers/registry.ts`)
```typescript
// Add import
import {
{service}EventATrigger,
{service}EventBTrigger,
{service}EventCTrigger,
{service}WebhookTrigger,
} from '@/triggers/{service}'
// Add to TRIGGER_REGISTRY
export const TRIGGER_REGISTRY: TriggerRegistry = {
// ... existing triggers ...
{service}_event_a: {service}EventATrigger,
{service}_event_b: {service}EventBTrigger,
{service}_event_c: {service}EventCTrigger,
{service}_webhook: {service}WebhookTrigger,
}
```
## Step 6: Connect Triggers to Block
In the block file (`apps/sim/blocks/blocks/{service}.ts`):
```typescript
import { {Service}Icon } from '@/components/icons'
import { getTrigger } from '@/triggers'
import type { BlockConfig } from '@/blocks/types'
export const {Service}Block: BlockConfig = {
type: '{service}',
name: '{Service}',
// ... other config ...
// Enable triggers and list available trigger IDs
triggers: {
enabled: true,
available: [
'{service}_event_a',
'{service}_event_b',
'{service}_event_c',
'{service}_webhook',
],
},
subBlocks: [
// Regular tool subBlocks first
{ id: 'operation', /* ... */ },
{ id: 'credential', /* ... */ },
// ... other tool fields ...
// Then spread ALL trigger subBlocks
...getTrigger('{service}_event_a').subBlocks,
...getTrigger('{service}_event_b').subBlocks,
...getTrigger('{service}_event_c').subBlocks,
...getTrigger('{service}_webhook').subBlocks,
],
// ... tools config ...
}
```
## Automatic Webhook Registration (Preferred)
If the service's API supports programmatic webhook creation, implement automatic webhook registration instead of requiring users to manually configure webhooks. This provides a much better user experience.
### When to Use Automatic Registration
Check the service's API documentation for endpoints like:
- `POST /webhooks` or `POST /hooks` - Create webhook
- `DELETE /webhooks/{id}` - Delete webhook
Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc.
### Implementation Steps
#### 1. Add API Key to Extra Fields
Update your `build{Service}ExtraFields` function to include an API key field:
```typescript
export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
return [
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your {Service} API key',
description: 'Required to create the webhook in {Service}.',
password: true,
required: true,
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
},
// Other optional fields (e.g., campaign filter, project filter)
{
id: 'projectId',
title: 'Project ID (Optional)',
type: 'short-input',
placeholder: 'Leave empty for all projects',
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
},
]
}
```
#### 2. Update Setup Instructions for Automatic Creation
Change instructions to indicate automatic webhook creation:
```typescript
export function {service}SetupInstructions(eventType: string): string {
const instructions = [
'Enter your {Service} API Key above.',
'You can find your API key in {Service} at <strong>Settings > API</strong>.',
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in {Service} for <strong>${eventType}</strong> events.`,
'The webhook will be automatically deleted when you remove this trigger.',
]
return instructions
.map((instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join('')
}
```
#### 3. Add Webhook Creation to API Route
In `apps/sim/app/api/webhooks/route.ts`, add provider-specific logic after the database save:
```typescript
// --- {Service} specific logic ---
if (savedWebhook && provider === '{service}') {
logger.info(`[${requestId}] {Service} provider detected. Creating webhook subscription.`)
try {
const result = await create{Service}WebhookSubscription(
{
id: savedWebhook.id,
path: savedWebhook.path,
providerConfig: savedWebhook.providerConfig,
},
requestId
)
if (result) {
// Update the webhook record with the external webhook ID
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: result.id,
}
await db
.update(webhook)
.set({
providerConfig: updatedConfig,
updatedAt: new Date(),
})
.where(eq(webhook.id, savedWebhook.id))
savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created {Service} webhook`, {
externalHookId: result.id,
webhookId: savedWebhook.id,
})
}
} catch (err) {
logger.error(
`[${requestId}] Error creating {Service} webhook subscription, rolling back webhook`,
err
)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to create webhook in {Service}',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End {Service} specific logic ---
```
Then add the helper function at the end of the file:
```typescript
async function create{Service}WebhookSubscription(
webhookData: any,
requestId: string
): Promise<{ id: string } | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, triggerId, projectId } = providerConfig || {}
if (!apiKey) {
throw new Error('{Service} API Key is required.')
}
// Map trigger IDs to service event types
const eventTypeMap: Record<string, string | undefined> = {
{service}_event_a: 'eventA',
{service}_event_b: 'eventB',
{service}_webhook: undefined, // Generic - no filter
}
const eventType = eventTypeMap[triggerId]
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const requestBody: Record<string, any> = {
url: notificationUrl,
}
if (eventType) {
requestBody.eventType = eventType
}
if (projectId) {
requestBody.projectId = projectId
}
const response = await fetch('https://api.{service}.com/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = await response.json()
if (!response.ok) {
const errorMessage = responseBody.message || 'Unknown API error'
let userFriendlyMessage = 'Failed to create webhook in {Service}'
if (response.status === 401) {
userFriendlyMessage = 'Invalid API Key. Please verify and try again.'
} else if (errorMessage) {
userFriendlyMessage = `{Service} error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
return { id: responseBody.id }
} catch (error: any) {
logger.error(`Exception during {Service} webhook creation`, { error: error.message })
throw error
}
}
```
#### 4. Add Webhook Deletion to Provider Subscriptions
In `apps/sim/lib/webhooks/provider-subscriptions.ts`:
1. Add a logger:
```typescript
const {service}Logger = createLogger('{Service}Webhook')
```
2. Add the delete function:
```typescript
export async function delete{Service}Webhook(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 || !externalId) {
{service}Logger.warn(`[${requestId}] Missing apiKey or externalId, skipping cleanup`)
return
}
const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!response.ok && response.status !== 404) {
{service}Logger.warn(`[${requestId}] Failed to delete webhook (non-fatal): ${response.status}`)
} else {
{service}Logger.info(`[${requestId}] Successfully deleted webhook ${externalId}`)
}
} catch (error) {
{service}Logger.warn(`[${requestId}] Error deleting webhook (non-fatal)`, error)
}
}
```
3. Add to `cleanupExternalWebhook`:
```typescript
export async function cleanupExternalWebhook(...): Promise<void> {
// ... existing providers ...
} else if (webhook.provider === '{service}') {
await delete{Service}Webhook(webhook, requestId)
}
}
```
### Key Points for Automatic Registration
- **API Key visibility**: Always use `password: true` for API key fields
- **Error handling**: Roll back the database webhook if external creation fails
- **External ID storage**: Save the external webhook ID in `providerConfig.externalId`
- **Graceful cleanup**: Don't fail webhook deletion if cleanup fails (use non-fatal logging)
- **User-friendly errors**: Map HTTP status codes to helpful error messages
## The buildTriggerSubBlocks Helper
This is the generic helper from `@/triggers` that creates consistent trigger subBlocks.
### Function Signature
```typescript
interface BuildTriggerSubBlocksOptions {
triggerId: string // e.g., 'service_event_a'
triggerOptions: Array<{ label: string; id: string }> // Dropdown options
includeDropdown?: boolean // true only for primary trigger
setupInstructions: string // HTML instructions
extraFields?: SubBlockConfig[] // Service-specific fields
webhookPlaceholder?: string // Custom placeholder text
}
function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[]
```
### What It Creates
The helper creates this structure:
1. **Dropdown** (only if `includeDropdown: true`) - Trigger type selector
2. **Webhook URL** - Read-only field with copy button
3. **Extra Fields** - Your service-specific fields (filters, options, etc.)
4. **Save Button** - Activates the trigger
5. **Instructions** - Setup guide for users
All fields automatically have:
- `mode: 'trigger'` - Only shown in trigger mode
- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected
## Trigger Outputs
Trigger outputs use the same schema as block outputs (NOT tool outputs).
**Supported:**
- `type` and `description` for simple fields
- Nested object structure for complex data
**NOT Supported:**
- `optional: true` (tool outputs only)
- `items` property (tool outputs only)
```typescript
export function buildOutputs(): Record<string, TriggerOutput> {
return {
// Simple fields
eventType: { type: 'string', description: 'Event type' },
timestamp: { type: 'string', description: 'When it occurred' },
// Complex data - use type: 'json'
payload: { type: 'json', description: 'Full event payload' },
// Nested structure
resource: {
id: { type: 'string', description: 'Resource ID' },
name: { type: 'string', description: 'Resource name' },
},
}
}
```
## Generic Webhook Trigger Pattern
For services with many event types, create a generic webhook that accepts all events:
```typescript
export const {service}WebhookTrigger: TriggerConfig = {
id: '{service}_webhook',
name: '{Service} Webhook (All Events)',
// ...
subBlocks: buildTriggerSubBlocks({
triggerId: '{service}_webhook',
triggerOptions: {service}TriggerOptions,
setupInstructions: {service}SetupInstructions('All Events'),
extraFields: [
// Event type filter (optional)
{
id: 'eventTypes',
title: 'Event Types',
type: 'dropdown',
multiSelect: true,
options: [
{ label: 'Event A', id: 'event_a' },
{ label: 'Event B', id: 'event_b' },
],
placeholder: 'Leave empty for all events',
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: '{service}_webhook' },
},
// Plus any other service-specific fields
...build{Service}ExtraFields('{service}_webhook'),
],
}),
}
```
## Checklist Before Finishing
### Utils
- [ ] Created `{service}TriggerOptions` array with all trigger IDs
- [ ] Created `{service}SetupInstructions` function with clear steps
- [ ] Created `build{Service}ExtraFields` for service-specific fields
- [ ] Created output builders for each trigger type
### Triggers
- [ ] Primary trigger has `includeDropdown: true`
- [ ] Secondary triggers do NOT have `includeDropdown`
- [ ] All triggers use `buildTriggerSubBlocks` helper
- [ ] All triggers have proper outputs defined
- [ ] Created `index.ts` barrel export
### Registration
- [ ] All triggers imported in `triggers/registry.ts`
- [ ] All triggers added to `TRIGGER_REGISTRY`
- [ ] Block has `triggers.enabled: true`
- [ ] Block has all trigger IDs in `triggers.available`
- [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks`
### Automatic Webhook Registration (if supported)
- [ ] Added API key field to `build{Service}ExtraFields` with `password: true`
- [ ] Updated setup instructions for automatic webhook creation
- [ ] Added provider-specific logic to `apps/sim/app/api/webhooks/route.ts`
- [ ] Added `create{Service}WebhookSubscription` helper function
- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts`
- [ ] Added provider to `cleanupExternalWebhook` function
### Testing
- [ ] Run `bun run type-check` to verify no TypeScript errors
- [ ] Restart dev server to pick up new triggers
- [ ] Test trigger UI shows correctly in the block
- [ ] Test automatic webhook creation works (if applicable)

View File

@@ -1853,6 +1853,23 @@ export function LinearIcon(props: React.SVGProps<SVGSVGElement>) {
)
}
export function LemlistIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
width='24'
height='24'
fill='none'
>
<rect width='24' height='24' rx='4' fill='#316BFF' />
<path d='M7 6h2v9h5v2H7V6Z' fill='white' />
<circle cx='17' cy='8' r='2' fill='white' />
</svg>
)
}
export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -54,6 +54,7 @@ import {
JiraIcon,
JiraServiceManagementIcon,
KalshiIcon,
LemlistIcon,
LinearIcon,
LinkedInIcon,
LinkupIcon,
@@ -176,6 +177,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
jira_service_management: JiraServiceManagementIcon,
kalshi: KalshiIcon,
knowledge: PackageSearchIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,

View File

@@ -70,6 +70,7 @@ Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgeb
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On mit SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling-Gruppen für E-Mail-Trigger |
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Workspace-/Organisations-Einladungen global deaktivieren |
<Callout type="warn">
BYOK ist nur im gehosteten Sim Studio verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen.

View File

@@ -17,7 +17,7 @@ Define permission groups to control what features and integrations team members
- **Allowed Model Providers** - Restrict which AI providers users can access (OpenAI, Anthropic, Google, etc.)
- **Allowed Blocks** - Control which workflow blocks are available
- **Platform Settings** - Hide Knowledge Base, disable MCP tools, or disable custom tools
- **Platform Settings** - Hide Knowledge Base, disable MCP tools, disable custom tools, or disable invitations
### Setup
@@ -68,6 +68,7 @@ For self-hosted deployments, enterprise features can be enabled via environment
| `ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | Permission groups for access restrictions |
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers |
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Globally disable workspace/organization invitations |
### Organization Management
@@ -87,6 +88,23 @@ curl -X POST https://your-instance/api/v1/admin/organizations/{orgId}/members \
-d '{"userId": "user-id-here", "role": "admin"}'
```
### Workspace Members
When invitations are disabled, use the Admin API to manage workspace memberships directly:
```bash
# Add a user to a workspace
curl -X POST https://your-instance/api/v1/admin/workspaces/{workspaceId}/members \
-H "x-admin-key: YOUR_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"userId": "user-id-here", "permissions": "write"}'
# Remove a user from a workspace
curl -X DELETE "https://your-instance/api/v1/admin/workspaces/{workspaceId}/members?userId=user-id-here" \
-H "x-admin-key: YOUR_ADMIN_API_KEY"
```
### Notes
- Enabling `ACCESS_CONTROL_ENABLED` automatically enables organizations, as access control requires organization membership.
- When `DISABLE_INVITATIONS` is set, users cannot send invitations. Use the Admin API to manage workspace and organization memberships instead.

View File

@@ -0,0 +1,95 @@
---
title: Lemlist
description: Manage outreach activities, leads, and send emails via Lemlist
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="lemlist"
color="#316BFF"
/>
## Usage Instructions
Integrate Lemlist into your workflow. Retrieve campaign activities and replies, get lead information, and send emails through the Lemlist inbox.
## Tools
### `lemlist_get_activities`
Retrieves campaign activities and steps performed, including email opens, clicks, replies, and other events.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Lemlist API key |
| `type` | string | No | Filter by activity type \(e.g., emailOpened, emailClicked, emailReplied, paused\) |
| `campaignId` | string | No | Filter by campaign ID |
| `leadId` | string | No | Filter by lead ID |
| `isFirst` | boolean | No | Filter for first activity only |
| `limit` | number | No | Number of results per request \(max 100, default 100\) |
| `offset` | number | No | Number of records to skip for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `activities` | array | List of activities |
### `lemlist_get_lead`
Retrieves lead information by email address or lead ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Lemlist API key |
| `email` | string | No | Lead email address \(use either email or id\) |
| `id` | string | No | Lead ID \(use either email or id\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `_id` | string | Lead ID |
| `email` | string | Lead email address |
| `firstName` | string | Lead first name |
| `lastName` | string | Lead last name |
| `companyName` | string | Company name |
| `jobTitle` | string | Job title |
| `companyDomain` | string | Company domain |
| `isPaused` | boolean | Whether the lead is paused |
| `campaignId` | string | Campaign ID the lead belongs to |
| `contactId` | string | Contact ID |
| `emailStatus` | string | Email deliverability status |
### `lemlist_send_email`
Sends an email to a contact through the Lemlist inbox.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Lemlist API key |
| `sendUserId` | string | Yes | Identifier for the user sending the message |
| `sendUserEmail` | string | Yes | Email address of the sender |
| `sendUserMailboxId` | string | Yes | Mailbox identifier for the sender |
| `contactId` | string | Yes | Recipient contact identifier |
| `leadId` | string | Yes | Associated lead identifier |
| `subject` | string | Yes | Email subject line |
| `message` | string | Yes | Email message body in HTML format |
| `cc` | json | No | Array of CC email addresses |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ok` | boolean | Whether the email was sent successfully |

View File

@@ -51,6 +51,7 @@
"jira_service_management",
"kalshi",
"knowledge",
"lemlist",
"linear",
"linkedin",
"linkup",

View File

@@ -70,6 +70,7 @@ Para implementaciones self-hosted, las funciones enterprise se pueden activar me
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Inicio de sesión único con SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Grupos de sondeo para activadores de correo electrónico |
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Desactivar globalmente invitaciones a espacios de trabajo/organizaciones |
<Callout type="warn">
BYOK solo está disponible en Sim Studio alojado. Las implementaciones autoalojadas configuran las claves de proveedor de IA directamente a través de variables de entorno.

View File

@@ -70,6 +70,7 @@ Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Authentification unique avec SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Groupes de sondage pour les déclencheurs d'e-mail |
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Désactiver globalement les invitations aux espaces de travail/organisations |
<Callout type="warn">
BYOK est uniquement disponible sur Sim Studio hébergé. Les déploiements auto-hébergés configurent les clés de fournisseur d'IA directement via les variables d'environnement.

View File

@@ -69,6 +69,7 @@ Sim Studioのホストキーの代わりに、AIモデルプロバイダー用
|----------|-------------|
| `SSO_ENABLED`、`NEXT_PUBLIC_SSO_ENABLED` | SAML/OIDCによるシングルサインオン |
| `CREDENTIAL_SETS_ENABLED`、`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | メールトリガー用のポーリンググループ |
| `DISABLE_INVITATIONS`、`NEXT_PUBLIC_DISABLE_INVITATIONS` | ワークスペース/組織への招待をグローバルに無効化 |
<Callout type="warn">
BYOKはホスト型Sim Studioでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。

View File

@@ -69,6 +69,7 @@ Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织
|----------|-------------|
| `SSO_ENABLED``NEXT_PUBLIC_SSO_ENABLED` | 使用 SAML/OIDC 的单点登录 |
| `CREDENTIAL_SETS_ENABLED``NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | 用于邮件触发器的轮询组 |
| `DISABLE_INVITATIONS``NEXT_PUBLIC_DISABLE_INVITATIONS` | 全局禁用工作区/组织邀请 |
<Callout type="warn">
BYOK 仅适用于托管版 Sim Studio。自托管部署需通过环境变量直接配置 AI 提供商密钥。

View File

@@ -26,6 +26,10 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
import {
InvitationsNotAllowedError,
validateInvitationsAllowed,
} from '@/executor/utils/permission-check'
const logger = createLogger('OrganizationInvitations')
@@ -116,6 +120,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
await validateInvitationsAllowed(session.user.id)
const { id: organizationId } = await params
const url = new URL(request.url)
const validateOnly = url.searchParams.get('validate') === 'true'
@@ -427,6 +433,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
},
})
} catch (error) {
if (error instanceof InvitationsNotAllowedError) {
return NextResponse.json({ error: error.message }, { status: 403 })
}
logger.error('Failed to create organization invitations', {
organizationId: (await params).id,
error,
@@ -486,10 +496,7 @@ export async function DELETE(
and(
eq(invitation.id, invitationId),
eq(invitation.organizationId, organizationId),
or(
eq(invitation.status, 'pending'),
eq(invitation.status, 'rejected') // Allow cancelling rejected invitations too
)
or(eq(invitation.status, 'pending'), eq(invitation.status, 'rejected'))
)
)
.returning()

View File

@@ -17,6 +17,12 @@
* Workspaces:
* GET /api/v1/admin/workspaces - List all workspaces
* GET /api/v1/admin/workspaces/:id - Get workspace details
* GET /api/v1/admin/workspaces/:id/members - List workspace members
* POST /api/v1/admin/workspaces/:id/members - Add/update workspace member
* DELETE /api/v1/admin/workspaces/:id/members?userId=X - Remove workspace member
* GET /api/v1/admin/workspaces/:id/members/:mid - Get workspace member details
* PATCH /api/v1/admin/workspaces/:id/members/:mid - Update workspace member permissions
* DELETE /api/v1/admin/workspaces/:id/members/:mid - Remove workspace member by ID
* GET /api/v1/admin/workspaces/:id/workflows - List workspace workflows
* DELETE /api/v1/admin/workspaces/:id/workflows - Delete all workspace workflows
* GET /api/v1/admin/workspaces/:id/folders - List workspace folders
@@ -95,6 +101,7 @@ export type {
AdminWorkflowDetail,
AdminWorkspace,
AdminWorkspaceDetail,
AdminWorkspaceMember,
DbMember,
DbOrganization,
DbSubscription,

View File

@@ -518,6 +518,22 @@ export interface AdminMemberDetail extends AdminMember {
billingBlocked: boolean
}
// =============================================================================
// Workspace Member Types
// =============================================================================
export interface AdminWorkspaceMember {
id: string
workspaceId: string
userId: string
permissions: 'admin' | 'write' | 'read'
createdAt: string
updatedAt: string
userName: string
userEmail: string
userImage: string | null
}
// =============================================================================
// User Billing Types
// =============================================================================

View File

@@ -0,0 +1,232 @@
/**
* GET /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Get workspace member details.
*
* Response: AdminSingleResponse<AdminWorkspaceMember>
*
* PATCH /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Update member permissions.
*
* Body:
* - permissions: 'admin' | 'write' | 'read' - New permission level
*
* Response: AdminSingleResponse<AdminWorkspaceMember>
*
* DELETE /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Remove member from workspace.
*
* Response: AdminSingleResponse<{ removed: true, memberId: string, userId: string }>
*/
import { db } from '@sim/db'
import { permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminWorkspaceMember } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceMemberDetailAPI')
interface RouteParams {
id: string
memberId: string
}
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
const { id: workspaceId, memberId } = await context.params
try {
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [memberData] = await db
.select({
id: permissions.id,
userId: permissions.userId,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
updatedAt: permissions.updatedAt,
userName: user.name,
userEmail: user.email,
userImage: user.image,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.where(
and(
eq(permissions.id, memberId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!memberData) {
return notFoundResponse('Workspace member')
}
const data: AdminWorkspaceMember = {
id: memberData.id,
workspaceId,
userId: memberData.userId,
permissions: memberData.permissionType,
createdAt: memberData.createdAt.toISOString(),
updatedAt: memberData.updatedAt.toISOString(),
userName: memberData.userName,
userEmail: memberData.userEmail,
userImage: memberData.userImage,
}
logger.info(`Admin API: Retrieved member ${memberId} from workspace ${workspaceId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get workspace member', { error, workspaceId, memberId })
return internalErrorResponse('Failed to get workspace member')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workspaceId, memberId } = await context.params
try {
const body = await request.json()
if (!body.permissions || !['admin', 'write', 'read'].includes(body.permissions)) {
return badRequestResponse('permissions must be "admin", "write", or "read"')
}
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [existingMember] = await db
.select({
id: permissions.id,
userId: permissions.userId,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
})
.from(permissions)
.where(
and(
eq(permissions.id, memberId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!existingMember) {
return notFoundResponse('Workspace member')
}
const now = new Date()
await db
.update(permissions)
.set({ permissionType: body.permissions, updatedAt: now })
.where(eq(permissions.id, memberId))
const [userData] = await db
.select({ name: user.name, email: user.email, image: user.image })
.from(user)
.where(eq(user.id, existingMember.userId))
.limit(1)
const data: AdminWorkspaceMember = {
id: existingMember.id,
workspaceId,
userId: existingMember.userId,
permissions: body.permissions,
createdAt: existingMember.createdAt.toISOString(),
updatedAt: now.toISOString(),
userName: userData?.name ?? '',
userEmail: userData?.email ?? '',
userImage: userData?.image ?? null,
}
logger.info(`Admin API: Updated member ${memberId} permissions to ${body.permissions}`, {
workspaceId,
previousPermissions: existingMember.permissionType,
})
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to update workspace member', { error, workspaceId, memberId })
return internalErrorResponse('Failed to update workspace member')
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (_, context) => {
const { id: workspaceId, memberId } = await context.params
try {
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [existingMember] = await db
.select({
id: permissions.id,
userId: permissions.userId,
})
.from(permissions)
.where(
and(
eq(permissions.id, memberId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!existingMember) {
return notFoundResponse('Workspace member')
}
await db.delete(permissions).where(eq(permissions.id, memberId))
logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, {
userId: existingMember.userId,
})
return singleResponse({
removed: true,
memberId,
userId: existingMember.userId,
workspaceId,
})
} catch (error) {
logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, memberId })
return internalErrorResponse('Failed to remove workspace member')
}
})

View File

@@ -0,0 +1,298 @@
/**
* GET /api/v1/admin/workspaces/[id]/members
*
* List all members of a workspace with their permission details.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminWorkspaceMember>
*
* POST /api/v1/admin/workspaces/[id]/members
*
* Add a user to a workspace with a specific permission level.
* If the user already has permissions, updates their permission level.
*
* Body:
* - userId: string - User ID to add
* - permissions: 'admin' | 'write' | 'read' - Permission level
*
* Response: AdminSingleResponse<AdminWorkspaceMember & { action: 'created' | 'updated' }>
*
* DELETE /api/v1/admin/workspaces/[id]/members
*
* Remove a user from a workspace.
*
* Query Parameters:
* - userId: string - User ID to remove
*
* Response: AdminSingleResponse<{ removed: true }>
*/
import crypto from 'crypto'
import { db } from '@sim/db'
import { permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq } from 'drizzle-orm'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminWorkspaceMember,
createPaginationMeta,
parsePaginationParams,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceMembersAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workspaceId } = await context.params
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
try {
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [countResult, membersData] = await Promise.all([
db
.select({ count: count() })
.from(permissions)
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))),
db
.select({
id: permissions.id,
userId: permissions.userId,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
updatedAt: permissions.updatedAt,
userName: user.name,
userEmail: user.email,
userImage: user.image,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)))
.orderBy(permissions.createdAt)
.limit(limit)
.offset(offset),
])
const total = countResult[0].count
const data: AdminWorkspaceMember[] = membersData.map((m) => ({
id: m.id,
workspaceId,
userId: m.userId,
permissions: m.permissionType,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
userName: m.userName,
userEmail: m.userEmail,
userImage: m.userImage,
}))
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} members for workspace ${workspaceId}`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list workspace members', { error, workspaceId })
return internalErrorResponse('Failed to list workspace members')
}
})
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workspaceId } = await context.params
try {
const body = await request.json()
if (!body.userId || typeof body.userId !== 'string') {
return badRequestResponse('userId is required')
}
if (!body.permissions || !['admin', 'write', 'read'].includes(body.permissions)) {
return badRequestResponse('permissions must be "admin", "write", or "read"')
}
const [workspaceData] = await db
.select({ id: workspace.id, name: workspace.name })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [userData] = await db
.select({ id: user.id, name: user.name, email: user.email, image: user.image })
.from(user)
.where(eq(user.id, body.userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const [existingPermission] = await db
.select({
id: permissions.id,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
updatedAt: permissions.updatedAt,
})
.from(permissions)
.where(
and(
eq(permissions.userId, body.userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (existingPermission) {
if (existingPermission.permissionType !== body.permissions) {
const now = new Date()
await db
.update(permissions)
.set({ permissionType: body.permissions, updatedAt: now })
.where(eq(permissions.id, existingPermission.id))
logger.info(
`Admin API: Updated user ${body.userId} permissions in workspace ${workspaceId}`,
{
previousPermissions: existingPermission.permissionType,
newPermissions: body.permissions,
}
)
return singleResponse({
id: existingPermission.id,
workspaceId,
userId: body.userId,
permissions: body.permissions as 'admin' | 'write' | 'read',
createdAt: existingPermission.createdAt.toISOString(),
updatedAt: now.toISOString(),
userName: userData.name,
userEmail: userData.email,
userImage: userData.image,
action: 'updated' as const,
})
}
return singleResponse({
id: existingPermission.id,
workspaceId,
userId: body.userId,
permissions: existingPermission.permissionType,
createdAt: existingPermission.createdAt.toISOString(),
updatedAt: existingPermission.updatedAt.toISOString(),
userName: userData.name,
userEmail: userData.email,
userImage: userData.image,
action: 'already_member' as const,
})
}
const now = new Date()
const permissionId = crypto.randomUUID()
await db.insert(permissions).values({
id: permissionId,
userId: body.userId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: body.permissions,
createdAt: now,
updatedAt: now,
})
logger.info(`Admin API: Added user ${body.userId} to workspace ${workspaceId}`, {
permissions: body.permissions,
permissionId,
})
return singleResponse({
id: permissionId,
workspaceId,
userId: body.userId,
permissions: body.permissions as 'admin' | 'write' | 'read',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
userName: userData.name,
userEmail: userData.email,
userImage: userData.image,
action: 'created' as const,
})
} catch (error) {
logger.error('Admin API: Failed to add workspace member', { error, workspaceId })
return internalErrorResponse('Failed to add workspace member')
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workspaceId } = await context.params
const url = new URL(request.url)
const userId = url.searchParams.get('userId')
try {
if (!userId) {
return badRequestResponse('userId query parameter is required')
}
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [existingPermission] = await db
.select({ id: permissions.id })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!existingPermission) {
return notFoundResponse('Workspace member')
}
await db.delete(permissions).where(eq(permissions.id, existingPermission.id))
logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`)
return singleResponse({ removed: true, userId, workspaceId })
} catch (error) {
logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, userId })
return internalErrorResponse('Failed to remove workspace member')
}
})

View File

@@ -1,79 +0,0 @@
import { db, webhook, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { signTestWebhookToken } from '@/lib/webhooks/test-tokens'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MintWebhookTestUrlAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await request.json().catch(() => ({}))
const ttlSeconds = Math.max(
60,
Math.min(60 * 60 * 24 * 30, Number(body?.ttlSeconds) || 60 * 60 * 24 * 7)
)
// Load webhook + workflow for permission check
const rows = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
userId: workflow.userId,
workspaceId: workflow.workspaceId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(eq(webhook.id, id))
.limit(1)
if (rows.length === 0) {
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
const wf = rows[0].workflow
// Permissions: owner OR workspace write/admin
let canMint = false
if (wf.userId === session.user.id) {
canMint = true
} else if (wf.workspaceId) {
const perm = await getUserEntityPermissions(session.user.id, 'workspace', wf.workspaceId)
if (perm === 'write' || perm === 'admin') {
canMint = true
}
}
if (!canMint) {
logger.warn(`[${requestId}] User ${session.user.id} denied mint for webhook ${id}`)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const token = await signTestWebhookToken(id, ttlSeconds)
const url = `${getBaseUrl()}/api/webhooks/test/${id}?token=${encodeURIComponent(token)}`
logger.info(`[${requestId}] Minted test URL for webhook ${id}`)
return NextResponse.json({
url,
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
})
} catch (error: any) {
logger.error('Error minting test webhook URL', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -793,6 +793,58 @@ export async function POST(request: NextRequest) {
}
// --- End Grain specific logic ---
// --- Lemlist specific logic ---
if (savedWebhook && provider === 'lemlist') {
logger.info(
`[${requestId}] Lemlist provider detected. Creating Lemlist webhook subscription.`
)
try {
const lemlistResult = await createLemlistWebhookSubscription(
{
id: savedWebhook.id,
path: savedWebhook.path,
providerConfig: savedWebhook.providerConfig,
},
requestId
)
if (lemlistResult) {
// Update the webhook record with the external Lemlist hook ID
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: lemlistResult.id,
}
await db
.update(webhook)
.set({
providerConfig: updatedConfig,
updatedAt: new Date(),
})
.where(eq(webhook.id, savedWebhook.id))
savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created Lemlist webhook`, {
lemlistHookId: lemlistResult.id,
webhookId: savedWebhook.id,
})
}
} catch (err) {
logger.error(
`[${requestId}] Error creating Lemlist webhook subscription, rolling back webhook`,
err
)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to create webhook in Lemlist',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Lemlist specific logic ---
if (!targetWebhookId && savedWebhook) {
try {
PlatformEvents.webhookCreated({
@@ -1316,3 +1368,116 @@ async function createGrainWebhookSubscription(
throw error
}
}
// Helper function to create the webhook subscription in Lemlist
async function createLemlistWebhookSubscription(
webhookData: any,
requestId: string
): Promise<{ id: string } | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, triggerId, campaignId } = providerConfig || {}
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.'
)
}
// Map trigger IDs to Lemlist event types
const eventTypeMap: Record<string, string | undefined> = {
lemlist_email_replied: 'emailsReplied',
lemlist_linkedin_replied: 'linkedinReplied',
lemlist_interested: 'interested',
lemlist_not_interested: 'notInterested',
lemlist_email_opened: 'emailsOpened',
lemlist_email_clicked: 'emailsClicked',
lemlist_email_bounced: 'emailsBounced',
lemlist_email_sent: 'emailsSent',
lemlist_webhook: undefined, // Generic webhook - no type filter
}
const eventType = eventTypeMap[triggerId]
logger.info(`[${requestId}] Creating Lemlist webhook`, {
triggerId,
eventType,
hasCampaignId: !!campaignId,
webhookId: webhookData.id,
})
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const lemlistApiUrl = 'https://api.lemlist.com/api/hooks'
// Build request body
const requestBody: Record<string, any> = {
targetUrl: notificationUrl,
}
// Add event type if specified (omit for generic webhook to receive all events)
if (eventType) {
requestBody.type = eventType
}
// Add campaign filter if specified
if (campaignId) {
requestBody.campaignId = campaignId
}
// Lemlist uses Basic Auth with empty username and API key as password
const authString = Buffer.from(`:${apiKey}`).toString('base64')
const lemlistResponse = await fetch(lemlistApiUrl, {
method: 'POST',
headers: {
Authorization: `Basic ${authString}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = await lemlistResponse.json()
if (!lemlistResponse.ok || responseBody.error) {
const errorMessage = responseBody.message || responseBody.error || 'Unknown Lemlist API error'
logger.error(
`[${requestId}] Failed to create webhook in Lemlist for webhook ${webhookData.id}. Status: ${lemlistResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist'
if (lemlistResponse.status === 401) {
userFriendlyMessage = 'Invalid Lemlist API Key. Please verify your API Key is correct.'
} else if (lemlistResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Lemlist API Key has appropriate permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Lemlist API error') {
userFriendlyMessage = `Lemlist error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Lemlist for webhook ${webhookData.id}.`,
{
lemlistWebhookId: responseBody._id,
}
)
return { id: responseBody._id }
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Lemlist webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
throw error
}
}

View File

@@ -1,114 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import {
checkWebhookPreprocessing,
findWebhookAndWorkflow,
handleProviderChallenges,
parseWebhookBody,
queueWebhookExecution,
verifyProviderAuth,
} from '@/lib/webhooks/processor'
import { verifyTestWebhookToken } from '@/lib/webhooks/test-tokens'
const logger = createLogger('WebhookTestReceiverAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const webhookId = (await params).id
logger.info(`[${requestId}] Test webhook request received for webhook ${webhookId}`)
const parseResult = await parseWebhookBody(request, requestId)
if (parseResult instanceof NextResponse) {
return parseResult
}
const { body, rawBody } = parseResult
const challengeResponse = await handleProviderChallenges(body, request, requestId, '')
if (challengeResponse) {
return challengeResponse
}
const url = new URL(request.url)
const token = url.searchParams.get('token')
if (!token) {
logger.warn(`[${requestId}] Test webhook request missing token`)
return new NextResponse('Unauthorized', { status: 401 })
}
const isValid = await verifyTestWebhookToken(token, webhookId)
if (!isValid) {
logger.warn(`[${requestId}] Invalid test webhook token`)
return new NextResponse('Unauthorized', { status: 401 })
}
const result = await findWebhookAndWorkflow({ requestId, webhookId })
if (!result) {
logger.warn(`[${requestId}] No active webhook found for id: ${webhookId}`)
return new NextResponse('Webhook not found', { status: 404 })
}
const { webhook: foundWebhook, workflow: foundWorkflow } = result
const authError = await verifyProviderAuth(
foundWebhook,
foundWorkflow,
request,
rawBody,
requestId
)
if (authError) {
return authError
}
let preprocessError: NextResponse | null = null
try {
// Test webhooks skip deployment check but still enforce rate limits and usage limits
// They run on live/draft state to allow testing before deployment
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId, {
isTestMode: true,
})
if (preprocessError) {
return preprocessError
}
} catch (error) {
logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
})
if (foundWebhook.provider === 'microsoft-teams') {
return NextResponse.json(
{
type: 'message',
text: 'An unexpected error occurred during preprocessing',
},
{ status: 500 }
)
}
return NextResponse.json(
{ error: 'An unexpected error occurred during preprocessing' },
{ status: 500 }
)
}
logger.info(
`[${requestId}] Executing TEST webhook for ${foundWebhook.provider} (workflow: ${foundWorkflow.id})`
)
return queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path: foundWebhook.path,
testMode: true,
executionTarget: 'live',
})
}

View File

@@ -1,522 +0,0 @@
import { db } from '@sim/db'
import { webhook } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('WebhookTestAPI')
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const { searchParams } = new URL(request.url)
const webhookId = searchParams.get('id')
if (!webhookId) {
logger.warn(`[${requestId}] Missing webhook ID in test request`)
return NextResponse.json({ success: false, error: 'Webhook ID is required' }, { status: 400 })
}
logger.debug(`[${requestId}] Testing webhook with ID: ${webhookId}`)
const webhooks = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1)
if (webhooks.length === 0) {
logger.warn(`[${requestId}] Webhook not found: ${webhookId}`)
return NextResponse.json({ success: false, error: 'Webhook not found' }, { status: 404 })
}
const foundWebhook = webhooks[0]
const provider = foundWebhook.provider || 'generic'
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${foundWebhook.path}`
logger.info(`[${requestId}] Testing webhook for provider: ${provider}`, {
webhookId,
path: foundWebhook.path,
isActive: foundWebhook.isActive,
})
switch (provider) {
case 'whatsapp': {
const verificationToken = providerConfig.verificationToken
if (!verificationToken) {
logger.warn(`[${requestId}] WhatsApp webhook missing verification token: ${webhookId}`)
return NextResponse.json(
{ success: false, error: 'Webhook has no verification token' },
{ status: 400 }
)
}
const challenge = `test_${Date.now()}`
const whatsappUrl = `${webhookUrl}?hub.mode=subscribe&hub.verify_token=${verificationToken}&hub.challenge=${challenge}`
logger.debug(`[${requestId}] Testing WhatsApp webhook verification`, {
webhookId,
challenge,
})
const response = await fetch(whatsappUrl, {
headers: {
'User-Agent': 'facebookplatform/1.0',
},
})
const status = response.status
const contentType = response.headers.get('content-type')
const responseText = await response.text()
const success = status === 200 && responseText === challenge
if (success) {
logger.info(`[${requestId}] WhatsApp webhook verification successful: ${webhookId}`)
} else {
logger.warn(`[${requestId}] WhatsApp webhook verification failed: ${webhookId}`, {
status,
contentType,
responseTextLength: responseText.length,
})
}
return NextResponse.json({
success,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
verificationToken,
isActive: foundWebhook.isActive,
},
test: {
status,
contentType,
responseText,
expectedStatus: 200,
expectedContentType: 'text/plain',
expectedResponse: challenge,
},
message: success
? 'Webhook configuration is valid. You can now use this URL in WhatsApp.'
: 'Webhook verification failed. Please check your configuration.',
diagnostics: {
statusMatch: status === 200 ? '✅ Status code is 200' : '❌ Status code should be 200',
contentTypeMatch:
contentType === 'text/plain'
? '✅ Content-Type is text/plain'
: '❌ Content-Type should be text/plain',
bodyMatch:
responseText === challenge
? '✅ Response body matches challenge'
: '❌ Response body should exactly match the challenge string',
},
})
}
case 'telegram': {
const botToken = providerConfig.botToken
if (!botToken) {
logger.warn(`[${requestId}] Telegram webhook missing configuration: ${webhookId}`)
return NextResponse.json(
{ success: false, error: 'Webhook has incomplete configuration' },
{ status: 400 }
)
}
const testMessage = {
update_id: 12345,
message: {
message_id: 67890,
from: {
id: 123456789,
first_name: 'Test',
username: 'testbot',
},
chat: {
id: 123456789,
first_name: 'Test',
username: 'testbot',
type: 'private',
},
date: Math.floor(Date.now() / 1000),
text: 'This is a test message',
},
}
logger.debug(`[${requestId}] Testing Telegram webhook connection`, {
webhookId,
url: webhookUrl,
})
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'TelegramBot/1.0',
},
body: JSON.stringify(testMessage),
})
const status = response.status
let responseText = ''
try {
responseText = await response.text()
} catch (_e) {}
const success = status >= 200 && status < 300
if (success) {
logger.info(`[${requestId}] Telegram webhook test successful: ${webhookId}`)
} else {
logger.warn(`[${requestId}] Telegram webhook test failed: ${webhookId}`, {
status,
responseText,
})
}
let webhookInfo = null
try {
const webhookInfoUrl = `https://api.telegram.org/bot${botToken}/getWebhookInfo`
const infoResponse = await fetch(webhookInfoUrl, {
headers: {
'User-Agent': 'TelegramBot/1.0',
},
})
if (infoResponse.ok) {
const infoJson = await infoResponse.json()
if (infoJson.ok) {
webhookInfo = infoJson.result
}
}
} catch (e) {
logger.warn(`[${requestId}] Failed to get Telegram webhook info`, e)
}
const curlCommand = [
`curl -X POST "${webhookUrl}"`,
`-H "Content-Type: application/json"`,
`-H "User-Agent: TelegramBot/1.0"`,
`-d '${JSON.stringify(testMessage, null, 2)}'`,
].join(' \\\n')
return NextResponse.json({
success,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
botToken: `${botToken.substring(0, 5)}...${botToken.substring(botToken.length - 5)}`, // Show partial token for security
isActive: foundWebhook.isActive,
},
test: {
status,
responseText,
webhookInfo,
},
message: success
? 'Telegram webhook appears to be working. Any message sent to your bot will trigger the workflow.'
: 'Telegram webhook test failed. Please check server logs for more details.',
curlCommand,
info: 'To fix issues with Telegram webhooks getting 403 Forbidden responses, ensure the webhook request includes a User-Agent header.',
})
}
case 'github': {
const contentType = providerConfig.contentType || 'application/json'
logger.info(`[${requestId}] GitHub webhook test successful: ${webhookId}`)
return NextResponse.json({
success: true,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
contentType,
isActive: foundWebhook.isActive,
},
message:
'GitHub webhook configuration is valid. Use this URL in your GitHub repository settings.',
setup: {
url: webhookUrl,
contentType,
events: ['push', 'pull_request', 'issues', 'issue_comment'],
},
})
}
case 'stripe': {
logger.info(`[${requestId}] Stripe webhook test successful: ${webhookId}`)
return NextResponse.json({
success: true,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
isActive: foundWebhook.isActive,
},
message: 'Stripe webhook configuration is valid. Use this URL in your Stripe dashboard.',
setup: {
url: webhookUrl,
events: [
'charge.succeeded',
'invoice.payment_succeeded',
'customer.subscription.created',
],
},
})
}
case 'generic': {
const token = providerConfig.token
const secretHeaderName = providerConfig.secretHeaderName
const requireAuth = providerConfig.requireAuth
const allowedIps = providerConfig.allowedIps
let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"`
if (requireAuth && token) {
if (secretHeaderName) {
curlCommand += ` -H "${secretHeaderName}: ${token}"`
} else {
curlCommand += ` -H "Authorization: Bearer ${token}"`
}
}
curlCommand += ` -d '{"event":"test_event","timestamp":"${new Date().toISOString()}"}'`
logger.info(`[${requestId}] General webhook test successful: ${webhookId}`)
return NextResponse.json({
success: true,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
isActive: foundWebhook.isActive,
},
message:
'General webhook configuration is valid. Use the URL and authentication details as needed.',
details: {
requireAuth: requireAuth || false,
hasToken: !!token,
hasCustomHeader: !!secretHeaderName,
customHeaderName: secretHeaderName,
hasIpRestrictions: Array.isArray(allowedIps) && allowedIps.length > 0,
},
test: {
curlCommand,
headers: requireAuth
? secretHeaderName
? { [secretHeaderName]: token }
: { Authorization: `Bearer ${token}` }
: {},
samplePayload: {
event: 'test_event',
timestamp: new Date().toISOString(),
},
},
})
}
case 'slack': {
const signingSecret = providerConfig.signingSecret
if (!signingSecret) {
logger.warn(`[${requestId}] Slack webhook missing signing secret: ${webhookId}`)
return NextResponse.json(
{ success: false, error: 'Webhook has no signing secret configured' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Slack webhook test successful: ${webhookId}`)
return NextResponse.json({
success: true,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
isActive: foundWebhook.isActive,
},
message:
'Slack webhook configuration is valid. Use this URL in your Slack Event Subscriptions settings.',
setup: {
url: webhookUrl,
events: ['message.channels', 'reaction_added', 'app_mention'],
signingSecretConfigured: true,
},
test: {
curlCommand: [
`curl -X POST "${webhookUrl}"`,
`-H "Content-Type: application/json"`,
`-H "X-Slack-Request-Timestamp: $(date +%s)"`,
`-H "X-Slack-Signature: v0=$(date +%s)"`,
`-d '{"type":"event_callback","event":{"type":"message","channel":"C0123456789","user":"U0123456789","text":"Hello from Slack!","ts":"1234567890.123456"},"team_id":"T0123456789"}'`,
].join(' \\\n'),
samplePayload: {
type: 'event_callback',
token: 'XXYYZZ',
team_id: 'T123ABC',
event: {
type: 'message',
user: 'U123ABC',
text: 'Hello from Slack!',
ts: '1234567890.1234',
},
event_id: 'Ev123ABC',
},
},
})
}
case 'airtable': {
const baseId = providerConfig.baseId
const tableId = providerConfig.tableId
const webhookSecret = providerConfig.webhookSecret
if (!baseId || !tableId) {
logger.warn(`[${requestId}] Airtable webhook missing Base ID or Table ID: ${webhookId}`)
return NextResponse.json(
{
success: false,
error: 'Webhook configuration is incomplete (missing Base ID or Table ID)',
},
{ status: 400 }
)
}
const samplePayload = {
webhook: {
id: 'whiYOUR_WEBHOOK_ID',
},
base: {
id: baseId,
},
payloadFormat: 'v0',
actionMetadata: {
source: 'tableOrViewChange',
sourceMetadata: {},
},
payloads: [
{
timestamp: new Date().toISOString(),
baseTransactionNumber: Date.now(),
changedTablesById: {
[tableId]: {
changedRecordsById: {
recSAMPLEID1: {
current: { cellValuesByFieldId: { fldSAMPLEID: 'New Value' } },
previous: { cellValuesByFieldId: { fldSAMPLEID: 'Old Value' } },
},
},
changedFieldsById: {},
changedViewsById: {},
},
},
},
],
}
let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"`
curlCommand += ` -d '${JSON.stringify(samplePayload, null, 2)}'`
logger.info(`[${requestId}] Airtable webhook test successful: ${webhookId}`)
return NextResponse.json({
success: true,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
baseId: baseId,
tableId: tableId,
secretConfigured: !!webhookSecret,
isActive: foundWebhook.isActive,
},
message:
'Airtable webhook configuration appears valid. Use the sample curl command to manually send a test payload to your webhook URL.',
test: {
curlCommand: curlCommand,
samplePayload: samplePayload,
},
})
}
case 'microsoft-teams': {
const hmacSecret = providerConfig.hmacSecret
if (!hmacSecret) {
logger.warn(`[${requestId}] Microsoft Teams webhook missing HMAC secret: ${webhookId}`)
return NextResponse.json(
{ success: false, error: 'Microsoft Teams webhook requires HMAC secret' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Microsoft Teams webhook test successful: ${webhookId}`)
return NextResponse.json({
success: true,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
isActive: foundWebhook.isActive,
},
message: 'Microsoft Teams outgoing webhook configuration is valid.',
setup: {
url: webhookUrl,
hmacSecretConfigured: !!hmacSecret,
instructions: [
'Create an outgoing webhook in Microsoft Teams',
'Set the callback URL to the webhook URL above',
'Copy the HMAC security token to the configuration',
'Users can trigger the webhook by @mentioning it in Teams',
],
},
test: {
curlCommand: `curl -X POST "${webhookUrl}" \\
-H "Content-Type: application/json" \\
-H "Authorization: HMAC <signature>" \\
-d '{"type":"message","text":"Hello from Microsoft Teams!","from":{"id":"test","name":"Test User"}}'`,
samplePayload: {
type: 'message',
id: '1234567890',
timestamp: new Date().toISOString(),
text: 'Hello Sim Bot!',
from: {
id: '29:1234567890abcdef',
name: 'Test User',
},
conversation: {
id: '19:meeting_abcdef@thread.v2',
},
},
},
})
}
default: {
logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`)
return NextResponse.json({
success: true,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
provider: foundWebhook.provider,
isActive: foundWebhook.isActive,
},
message:
'Webhook configuration is valid. You can use this URL to receive webhook events.',
})
}
}
} catch (error: any) {
logger.error(`[${requestId}] Error testing webhook`, error)
return NextResponse.json(
{
success: false,
error: 'Test failed',
message: error.message,
},
{ status: 500 }
)
}
}

View File

@@ -152,7 +152,6 @@ export async function POST(
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
testMode: false,
executionTarget: 'deployed',
})
responses.push(response)

View File

@@ -101,6 +101,16 @@ describe('Workspace Invitations API Route', () => {
eq: vi.fn().mockImplementation((field, value) => ({ type: 'eq', field, value })),
inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })),
}))
vi.doMock('@/executor/utils/permission-check', () => ({
validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined),
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {
constructor() {
super('Invitations are not allowed based on your permission group settings')
this.name = 'InvitationsNotAllowedError'
}
},
}))
})
describe('GET /api/workspaces/invitations', () => {

View File

@@ -18,6 +18,10 @@ import { PlatformEvents } from '@/lib/core/telemetry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import {
InvitationsNotAllowedError,
validateInvitationsAllowed,
} from '@/executor/utils/permission-check'
export const dynamic = 'force-dynamic'
@@ -76,6 +80,8 @@ export async function POST(req: NextRequest) {
}
try {
await validateInvitationsAllowed(session.user.id)
const { workspaceId, email, role = 'member', permission = 'read' } = await req.json()
if (!workspaceId || !email) {
@@ -213,6 +219,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, invitation: invitationData })
} catch (error) {
if (error instanceof InvitationsNotAllowedError) {
return NextResponse.json({ error: error.message }, { status: 403 })
}
logger.error('Error creating workspace invitation:', error)
return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 })
}

View File

@@ -34,6 +34,7 @@ export function PaneContextMenu({
disableAdmin = false,
canUndo = false,
canRedo = false,
isInvitationsDisabled = false,
}: PaneContextMenuProps) {
return (
<Popover
@@ -142,17 +143,21 @@ export function PaneContextMenu({
{isChatOpen ? 'Close Chat' : 'Open Chat'}
</PopoverItem>
{/* Admin action */}
<PopoverDivider />
<PopoverItem
disabled={disableAdmin}
onClick={() => {
onInvite()
onClose()
}}
>
Invite to Workspace
</PopoverItem>
{/* Admin action - hidden when invitations are disabled */}
{!isInvitationsDisabled && (
<>
<PopoverDivider />
<PopoverItem
disabled={disableAdmin}
onClick={() => {
onInvite()
onClose()
}}
>
Invite to Workspace
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>
)

View File

@@ -94,4 +94,6 @@ export interface PaneContextMenuProps {
canUndo?: boolean
/** Whether redo is available */
canRedo?: boolean
/** Whether invitations are disabled (feature flag or permission group) */
isInvitationsDisabled?: boolean
}

View File

@@ -16,7 +16,6 @@ import { useWebhookManagement } from '@/hooks/use-webhook-management'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
import { ShortInput } from '../short-input/short-input'
const logger = createLogger('TriggerSave')
@@ -41,22 +40,6 @@ export function TriggerSave({
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [deleteStatus, setDeleteStatus] = useState<'idle' | 'deleting'>('idle')
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false)
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl')) as
| string
| null
const storedTestUrlExpiresAt = useSubBlockStore((state) =>
state.getValue(blockId, 'testUrlExpiresAt')
) as string | null
const isTestUrlExpired = useMemo(() => {
if (!storedTestUrlExpiresAt) return true
return new Date(storedTestUrlExpiresAt) < new Date()
}, [storedTestUrlExpiresAt])
const testUrl = isTestUrlExpired ? null : (storedTestUrl as string | null)
const testUrlExpiresAt = isTestUrlExpired ? null : (storedTestUrlExpiresAt as string | null)
const effectiveTriggerId = useMemo(() => {
if (triggerId && isTriggerValid(triggerId)) {
@@ -86,9 +69,6 @@ export function TriggerSave({
const triggerDef =
effectiveTriggerId && isTriggerValid(effectiveTriggerId) ? getTrigger(effectiveTriggerId) : null
const hasWebhookUrlDisplay =
triggerDef?.subBlocks.some((sb) => sb.id === 'webhookUrlDisplay') ?? false
const validateRequiredFields = useCallback(
(
configToCheck: Record<string, any> | null | undefined
@@ -212,13 +192,6 @@ export function TriggerSave({
validateRequiredFields,
])
useEffect(() => {
if (isTestUrlExpired && storedTestUrl) {
useSubBlockStore.getState().setValue(blockId, 'testUrl', null)
useSubBlockStore.getState().setValue(blockId, 'testUrlExpiresAt', null)
}
}, [blockId, isTestUrlExpired, storedTestUrl])
const handleSave = async () => {
if (isPreview || disabled) return
@@ -278,34 +251,6 @@ export function TriggerSave({
}
}
const generateTestUrl = async () => {
if (!webhookId) return
try {
setIsGeneratingTestUrl(true)
const res = await fetch(`/api/webhooks/${webhookId}/test-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err?.error || 'Failed to generate test URL')
}
const json = await res.json()
useSubBlockStore.getState().setValue(blockId, 'testUrl', json.url)
useSubBlockStore.getState().setValue(blockId, 'testUrlExpiresAt', json.expiresAt)
collaborativeSetSubblockValue(blockId, 'testUrl', json.url)
collaborativeSetSubblockValue(blockId, 'testUrlExpiresAt', json.expiresAt)
} catch (e) {
logger.error('Failed to generate test webhook URL', { error: e })
setErrorMessage(
e instanceof Error ? e.message : 'Failed to generate test URL. Please try again.'
)
} finally {
setIsGeneratingTestUrl(false)
}
}
const handleDeleteClick = () => {
if (isPreview || disabled || !webhookId) return
setShowDeleteDialog(true)
@@ -324,14 +269,9 @@ export function TriggerSave({
setSaveStatus('idle')
setErrorMessage(null)
useSubBlockStore.getState().setValue(blockId, 'testUrl', null)
useSubBlockStore.getState().setValue(blockId, 'testUrlExpiresAt', null)
collaborativeSetSubblockValue(blockId, 'triggerPath', '')
collaborativeSetSubblockValue(blockId, 'webhookId', null)
collaborativeSetSubblockValue(blockId, 'triggerConfig', null)
collaborativeSetSubblockValue(blockId, 'testUrl', null)
collaborativeSetSubblockValue(blockId, 'testUrlExpiresAt', null)
logger.info('Trigger configuration deleted successfully', {
blockId,
@@ -383,51 +323,6 @@ export function TriggerSave({
{errorMessage && <p className='mt-2 text-[12px] text-[var(--text-error)]'>{errorMessage}</p>}
{webhookId && hasWebhookUrlDisplay && (
<div className='mt-4 space-y-2'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Test Webhook URL
</span>
<Button
variant='ghost'
onClick={generateTestUrl}
disabled={isGeneratingTestUrl || isProcessing}
>
{isGeneratingTestUrl ? 'Generating…' : testUrl ? 'Regenerate' : 'Generate'}
</Button>
</div>
{testUrl ? (
<>
<ShortInput
blockId={blockId}
subBlockId={`${subBlockId}-test-url`}
config={{
id: `${subBlockId}-test-url`,
type: 'short-input',
readOnly: true,
showCopyButton: true,
}}
value={testUrl}
readOnly={true}
showCopyButton={true}
disabled={isPreview || disabled}
isPreview={isPreview}
/>
{testUrlExpiresAt && (
<p className='text-[12px] text-[var(--text-tertiary)]'>
Expires {new Date(testUrlExpiresAt).toLocaleString()}
</p>
)}
</>
) : (
<p className='text-[12px] text-[var(--text-tertiary)]'>
Generate a temporary URL to test against the live (undeployed) workflow state.
</p>
)}
</div>
)}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalHeader>Delete Trigger</ModalHeader>

View File

@@ -64,6 +64,7 @@ import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock } from '@/executor/constants'
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useChatStore } from '@/stores/chat/store'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
@@ -281,6 +282,9 @@ const WorkflowContent = React.memo(() => {
// Panel open states for context menu
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
const isChatOpen = useChatStore((state) => state.isChatOpen)
// Permission config for invitation control
const { isInvitationsDisabled } = usePermissionConfig()
const snapGrid: [number, number] = useMemo(
() => [snapToGridSize, snapToGridSize],
[snapToGridSize]
@@ -3426,6 +3430,7 @@ const WorkflowContent = React.memo(() => {
disableAdmin={!effectivePermissions.canAdmin}
canUndo={canUndo}
canRedo={canRedo}
isInvitationsDisabled={isInvitationsDisabled}
/>
</>
)}

View File

@@ -342,6 +342,12 @@ export function AccessControl() {
category: 'Logs',
configKey: 'hideTraceSpans' as const,
},
{
id: 'disable-invitations',
label: 'Invitations',
category: 'Collaboration',
configKey: 'disableInvitations' as const,
},
],
[]
)
@@ -869,7 +875,8 @@ export function AccessControl() {
!editingConfig?.hideFilesTab &&
!editingConfig?.disableMcpTools &&
!editingConfig?.disableCustomTools &&
!editingConfig?.hideTraceSpans
!editingConfig?.hideTraceSpans &&
!editingConfig?.disableInvitations
setEditingConfig((prev) =>
prev
? {
@@ -883,6 +890,7 @@ export function AccessControl() {
disableMcpTools: allVisible,
disableCustomTools: allVisible,
hideTraceSpans: allVisible,
disableInvitations: allVisible,
}
: prev
)
@@ -896,7 +904,8 @@ export function AccessControl() {
!editingConfig?.hideFilesTab &&
!editingConfig?.disableMcpTools &&
!editingConfig?.disableCustomTools &&
!editingConfig?.hideTraceSpans
!editingConfig?.hideTraceSpans &&
!editingConfig?.disableInvitations
? 'Deselect All'
: 'Select All'}
</Button>

View File

@@ -33,11 +33,13 @@ import {
} from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { useAdminWorkspaces } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('TeamManagement')
export function TeamManagement() {
const { data: session } = useSession()
const { isInvitationsDisabled } = usePermissionConfig()
const { data: organizationsData } = useOrganizations()
const activeOrganization = organizationsData?.activeOrganization
@@ -385,8 +387,8 @@ export function TeamManagement() {
</div>
)}
{/* Action: Invite New Members */}
{adminOrOwner && (
{/* Action: Invite New Members - hidden when invitations are disabled */}
{adminOrOwner && !isInvitationsDisabled && (
<div>
<MemberInvitationCard
inviteEmail={inviteEmail}

View File

@@ -18,6 +18,7 @@ import {
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('WorkspaceHeader')
@@ -151,12 +152,18 @@ export function WorkspaceHeader({
setIsMounted(true)
}, [])
const { isInvitationsDisabled } = usePermissionConfig()
// Listen for open-invite-modal event from context menu
useEffect(() => {
const handleOpenInvite = () => setIsInviteModalOpen(true)
const handleOpenInvite = () => {
if (!isInvitationsDisabled) {
setIsInviteModalOpen(true)
}
}
window.addEventListener('open-invite-modal', handleOpenInvite)
return () => window.removeEventListener('open-invite-modal', handleOpenInvite)
}, [])
}, [isInvitationsDisabled])
/**
* Focus the inline list rename input when it becomes active
@@ -458,8 +465,8 @@ export function WorkspaceHeader({
</div>
{/* Workspace Actions */}
<div className='flex flex-shrink-0 items-center gap-[10px]'>
{/* Invite - hidden in collapsed mode */}
{!isCollapsed && (
{/* Invite - hidden in collapsed mode or when invitations are disabled */}
{!isCollapsed && !isInvitationsDisabled && (
<Badge className='cursor-pointer' onClick={() => setIsInviteModalOpen(true)}>
Invite
</Badge>

View File

@@ -92,7 +92,6 @@ export type WebhookExecutionPayload = {
headers: Record<string, string>
path: string
blockId?: string
testMode?: boolean
executionTarget?: 'deployed' | 'live'
credentialId?: string
credentialAccountUserId?: string
@@ -318,7 +317,7 @@ async function executeWebhookJobInternal(
workspaceId,
variables: {},
triggerData: {
isTest: payload.testMode === true,
isTest: false,
executionTarget: payload.executionTarget || 'deployed',
},
deploymentVersionId,
@@ -376,7 +375,7 @@ async function executeWebhookJobInternal(
workspaceId,
variables: {},
triggerData: {
isTest: payload.testMode === true,
isTest: false,
executionTarget: payload.executionTarget || 'deployed',
},
deploymentVersionId,
@@ -595,7 +594,7 @@ async function executeWebhookJobInternal(
workspaceId: errorWorkspaceId,
variables: {},
triggerData: {
isTest: payload.testMode === true,
isTest: false,
executionTarget: payload.executionTarget || 'deployed',
},
deploymentVersionId,

View File

@@ -0,0 +1,240 @@
import { LemlistIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import type { LemlistResponse } from '@/tools/lemlist/types'
import { getTrigger } from '@/triggers'
export const LemlistBlock: BlockConfig<LemlistResponse> = {
type: 'lemlist',
name: 'Lemlist',
description: 'Manage outreach activities, leads, and send emails via Lemlist',
authMode: AuthMode.ApiKey,
longDescription:
'Integrate Lemlist into your workflow. Retrieve campaign activities and replies, get lead information, and send emails through the Lemlist inbox.',
docsLink: 'https://docs.sim.ai/tools/lemlist',
category: 'tools',
bgColor: '#316BFF',
icon: LemlistIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Get Activities', id: 'get_activities' },
{ label: 'Get Lead', id: 'get_lead' },
{ label: 'Send Email', id: 'send_email' },
],
value: () => 'get_activities',
},
{
id: 'type',
title: 'Activity Type',
type: 'dropdown',
options: [
{ label: 'All', id: '' },
{ label: 'Email Opened', id: 'emailOpened' },
{ label: 'Email Clicked', id: 'emailClicked' },
{ label: 'Email Replied', id: 'emailReplied' },
{ label: 'Email Sent', id: 'emailsSent' },
{ label: 'Email Bounced', id: 'emailsBounced' },
{ label: 'Paused', id: 'paused' },
{ label: 'Interested', id: 'interested' },
{ label: 'Not Interested', id: 'notInterested' },
],
value: () => '',
condition: { field: 'operation', value: 'get_activities' },
},
{
id: 'campaignId',
title: 'Campaign ID',
type: 'short-input',
placeholder: 'Filter by campaign ID (optional)',
condition: { field: 'operation', value: 'get_activities' },
},
{
id: 'filterLeadId',
title: 'Lead ID',
type: 'short-input',
placeholder: 'Filter by lead ID (optional)',
condition: { field: 'operation', value: 'get_activities' },
},
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: '100 (max)',
condition: { field: 'operation', value: 'get_activities' },
},
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: 'get_activities' },
},
{
id: 'email',
title: 'Email Address',
type: 'short-input',
placeholder: 'Enter lead email address',
condition: { field: 'operation', value: 'get_lead' },
mode: 'basic',
canonicalParamId: 'leadIdentifier',
},
{
id: 'leadIdLookup',
title: 'Lead ID',
type: 'short-input',
placeholder: 'Enter lead ID',
condition: { field: 'operation', value: 'get_lead' },
mode: 'advanced',
canonicalParamId: 'leadIdentifier',
},
{
id: 'sendUserId',
title: 'Sender User ID',
type: 'short-input',
placeholder: 'Your Lemlist user ID',
required: { field: 'operation', value: 'send_email' },
condition: { field: 'operation', value: 'send_email' },
},
{
id: 'sendUserEmail',
title: 'Sender Email',
type: 'short-input',
placeholder: 'Your email address',
required: { field: 'operation', value: 'send_email' },
condition: { field: 'operation', value: 'send_email' },
},
{
id: 'sendUserMailboxId',
title: 'Mailbox ID',
type: 'short-input',
placeholder: 'Your mailbox ID',
required: { field: 'operation', value: 'send_email' },
condition: { field: 'operation', value: 'send_email' },
},
{
id: 'contactId',
title: 'Contact ID',
type: 'short-input',
placeholder: 'Recipient contact ID',
required: { field: 'operation', value: 'send_email' },
condition: { field: 'operation', value: 'send_email' },
},
{
id: 'leadId',
title: 'Lead ID',
type: 'short-input',
placeholder: 'Associated lead ID',
required: { field: 'operation', value: 'send_email' },
condition: { field: 'operation', value: 'send_email' },
},
{
id: 'subject',
title: 'Subject',
type: 'short-input',
placeholder: 'Email subject',
required: { field: 'operation', value: 'send_email' },
condition: { field: 'operation', value: 'send_email' },
},
{
id: 'message',
title: 'Message',
type: 'long-input',
placeholder: 'Email message body (HTML supported)',
required: { field: 'operation', value: 'send_email' },
condition: { field: 'operation', value: 'send_email' },
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
required: true,
placeholder: 'Enter your Lemlist API key',
password: true,
},
// Trigger subBlocks - first trigger has dropdown, others don't
...getTrigger('lemlist_email_replied').subBlocks,
...getTrigger('lemlist_linkedin_replied').subBlocks,
...getTrigger('lemlist_interested').subBlocks,
...getTrigger('lemlist_not_interested').subBlocks,
...getTrigger('lemlist_email_opened').subBlocks,
...getTrigger('lemlist_email_clicked').subBlocks,
...getTrigger('lemlist_email_bounced').subBlocks,
...getTrigger('lemlist_email_sent').subBlocks,
...getTrigger('lemlist_webhook').subBlocks,
],
tools: {
access: ['lemlist_get_activities', 'lemlist_get_lead', 'lemlist_send_email'],
config: {
tool: (params) => {
if (params.limit) {
params.limit = Number(params.limit)
}
if (params.offset) {
params.offset = Number(params.offset)
}
// Map filterLeadId to leadId for get_activities tool
if (params.filterLeadId) {
params.leadId = params.filterLeadId
}
switch (params.operation) {
case 'get_activities':
return 'lemlist_get_activities'
case 'get_lead':
return 'lemlist_get_lead'
case 'send_email':
return 'lemlist_send_email'
default:
return 'lemlist_get_activities'
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'Lemlist API key' },
type: { type: 'string', description: 'Activity type filter' },
campaignId: { type: 'string', description: 'Campaign ID filter' },
filterLeadId: { type: 'string', description: 'Lead ID filter for activities' },
leadId: { type: 'string', description: 'Lead ID for send email' },
limit: { type: 'number', description: 'Result limit' },
offset: { type: 'number', description: 'Result offset' },
leadIdentifier: { type: 'string', description: 'Lead email address or ID' },
sendUserId: { type: 'string', description: 'Sender user ID' },
sendUserEmail: { type: 'string', description: 'Sender email address' },
sendUserMailboxId: { type: 'string', description: 'Sender mailbox ID' },
contactId: { type: 'string', description: 'Recipient contact ID' },
subject: { type: 'string', description: 'Email subject' },
message: { type: 'string', description: 'Email message body' },
},
outputs: {
activities: { type: 'json', description: 'List of campaign activities' },
count: { type: 'number', description: 'Number of activities returned' },
_id: { type: 'string', description: 'Lead ID' },
email: { type: 'string', description: 'Lead email' },
firstName: { type: 'string', description: 'Lead first name' },
lastName: { type: 'string', description: 'Lead last name' },
companyName: { type: 'string', description: 'Company name' },
jobTitle: { type: 'string', description: 'Job title' },
isPaused: { type: 'boolean', description: 'Whether lead is paused' },
emailStatus: { type: 'string', description: 'Email deliverability status' },
ok: { type: 'boolean', description: 'Whether email was sent successfully' },
},
triggers: {
enabled: true,
available: [
'lemlist_email_replied',
'lemlist_linkedin_replied',
'lemlist_interested',
'lemlist_not_interested',
'lemlist_email_opened',
'lemlist_email_clicked',
'lemlist_email_bounced',
'lemlist_email_sent',
'lemlist_webhook',
],
},
}

View File

@@ -60,6 +60,7 @@ import { JiraBlock } from '@/blocks/blocks/jira'
import { JiraServiceManagementBlock } from '@/blocks/blocks/jira_service_management'
import { KalshiBlock } from '@/blocks/blocks/kalshi'
import { KnowledgeBlock } from '@/blocks/blocks/knowledge'
import { LemlistBlock } from '@/blocks/blocks/lemlist'
import { LinearBlock } from '@/blocks/blocks/linear'
import { LinkedInBlock } from '@/blocks/blocks/linkedin'
import { LinkupBlock } from '@/blocks/blocks/linkup'
@@ -213,6 +214,7 @@ export const registry: Record<string, BlockConfig> = {
jira_service_management: JiraServiceManagementBlock,
kalshi: KalshiBlock,
knowledge: KnowledgeBlock,
lemlist: LemlistBlock,
linear: LinearBlock,
linkedin: LinkedInBlock,
linkup: LinkupBlock,

View File

@@ -1853,6 +1853,31 @@ export function LinearIcon(props: React.SVGProps<SVGSVGElement>) {
)
}
export function LemlistIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 181' fill='none'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M32.0524 0.919922H147.948C165.65 0.919922 180 15.2703 180 32.9723V148.867C180 166.57 165.65 180.92 147.948 180.92H32.0524C14.3504 180.92 0 166.57 0 148.867V32.9723C0 15.2703 14.3504 0.919922 32.0524 0.919922ZM119.562 82.8879H85.0826C82.4732 82.8879 80.3579 85.0032 80.3579 87.6126V94.2348C80.3579 96.8442 82.4732 98.9595 85.0826 98.9595H119.562C122.171 98.9595 124.286 96.8442 124.286 94.2348V87.6126C124.286 85.0032 122.171 82.8879 119.562 82.8879ZM85.0826 49.1346H127.061C129.67 49.1346 131.785 51.2499 131.785 53.8593V60.4815C131.785 63.0909 129.67 65.2062 127.061 65.2062H85.0826C82.4732 65.2062 80.3579 63.0909 80.3579 60.4815V53.8593C80.3579 51.2499 82.4732 49.1346 85.0826 49.1346ZM131.785 127.981V121.358C131.785 118.75 129.669 116.634 127.061 116.634H76.5706C69.7821 116.634 64.2863 111.138 64.2863 104.349V53.8593C64.2863 51.2513 62.1697 49.1346 59.5616 49.1346H52.9395C50.3314 49.1346 48.2147 51.2513 48.2147 53.8593V114.199C48.8497 124.133 56.7873 132.07 66.7205 132.705H127.061C129.669 132.705 131.785 130.589 131.785 127.981Z'
fill='#316BFF'
/>
<path
d='M85.0826 49.1346H127.061C129.67 49.1346 131.785 51.2499 131.785 53.8593V60.4815C131.785 63.0909 129.67 65.2062 127.061 65.2062H85.0826C82.4732 65.2062 80.3579 63.0909 80.3579 60.4815V53.8593C80.3579 51.2499 82.4732 49.1346 85.0826 49.1346Z'
fill='white'
/>
<path
d='M85.0826 82.8879H119.562C122.171 82.8879 124.286 85.0032 124.286 87.6126V94.2348C124.286 96.8442 122.171 98.9595 119.562 98.9595H85.0826C82.4732 98.9595 80.3579 96.8442 80.3579 94.2348V87.6126C80.3579 85.0032 82.4732 82.8879 85.0826 82.8879Z'
fill='white'
/>
<path
d='M131.785 121.358V127.981C131.785 130.589 129.669 132.705 127.061 132.705H66.7205C56.7873 132.07 48.8497 124.133 48.2147 114.199V53.8593C48.2147 51.2513 50.3314 49.1346 52.9395 49.1346H59.5616C62.1697 49.1346 64.2863 51.2513 64.2863 53.8593V104.349C64.2863 111.138 69.7821 116.634 76.5706 116.634H127.061C129.669 116.634 131.785 118.75 131.785 121.358Z'
fill='white'
/>
</svg>
)
}
export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -42,6 +42,13 @@ export class CustomToolsNotAllowedError extends Error {
}
}
export class InvitationsNotAllowedError extends Error {
constructor() {
super('Invitations are not allowed based on your permission group settings')
this.name = 'InvitationsNotAllowedError'
}
}
export async function getUserPermissionConfig(
userId: string
): Promise<PermissionGroupConfig | null> {
@@ -184,3 +191,30 @@ export async function validateCustomToolsAllowed(
throw new CustomToolsNotAllowedError()
}
}
/**
* Validates if the user is allowed to send invitations.
* Also checks the global feature flag.
*/
export async function validateInvitationsAllowed(userId: string | undefined): Promise<void> {
const { isInvitationsDisabled } = await import('@/lib/core/config/feature-flags')
if (isInvitationsDisabled) {
logger.warn('Invitations blocked by feature flag')
throw new InvitationsNotAllowedError()
}
if (!userId) {
return
}
const config = await getUserPermissionConfig(userId)
if (!config) {
return
}
if (config.disableInvitations) {
logger.warn('Invitations blocked by permission group', { userId })
throw new InvitationsNotAllowedError()
}
}

View File

@@ -1088,25 +1088,28 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown',
})
const currentValue = subBlockStore.getValue(blockId, subblockId)
const valueActuallyChanged = currentValue !== value
subBlockStore.setValue(blockId, subblockId, value)
try {
const visited = options?._visited || new Set<string>()
if (visited.has(subblockId)) return
visited.add(subblockId)
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
const blockConfig = blockType ? getBlock(blockType) : null
if (blockConfig?.subBlocks && Array.isArray(blockConfig.subBlocks)) {
const dependents = blockConfig.subBlocks.filter(
(sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId)
)
for (const dep of dependents) {
if (!dep?.id || dep.id === subblockId) continue
collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited })
if (valueActuallyChanged) {
try {
const visited = options?._visited || new Set<string>()
if (visited.has(subblockId)) return
visited.add(subblockId)
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
const blockConfig = blockType ? getBlock(blockType) : null
if (blockConfig?.subBlocks && Array.isArray(blockConfig.subBlocks)) {
const dependents = blockConfig.subBlocks.filter(
(sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId)
)
for (const dep of dependents) {
if (!dep?.id || dep.id === subblockId) continue
collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited })
}
}
}
} catch {
// Best-effort; do not block on clearing
} catch {}
}
},
[

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
@@ -14,6 +15,7 @@ export interface PermissionConfigResult {
filterProviders: (providerIds: string[]) => string[]
isBlockAllowed: (blockType: string) => boolean
isProviderAllowed: (providerId: string) => boolean
isInvitationsDisabled: boolean
}
export function usePermissionConfig(): PermissionConfigResult {
@@ -59,6 +61,11 @@ export function usePermissionConfig(): PermissionConfigResult {
}
}, [config.allowedModelProviders])
const isInvitationsDisabled = useMemo(() => {
const featureFlagDisabled = isTruthy(getEnv('NEXT_PUBLIC_DISABLE_INVITATIONS'))
return featureFlagDisabled || config.disableInvitations
}, [config.disableInvitations])
return {
config,
isLoading,
@@ -67,5 +74,6 @@ export function usePermissionConfig(): PermissionConfigResult {
filterProviders,
isBlockAllowed,
isProviderAllowed,
isInvitationsDisabled,
}
}

View File

@@ -2004,7 +2004,7 @@ function applyOperationsToWorkflowState(
validationErrors.push(...validationResult.errors)
Object.entries(validationResult.validInputs).forEach(([key, value]) => {
// Skip runtime subblock IDs (webhookId, triggerPath, testUrl, testUrlExpiresAt)
// Skip runtime subblock IDs (webhookId, triggerPath)
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) {
return
}

View File

@@ -257,6 +257,9 @@ export const env = createEnv({
// Organizations - for self-hosted deployments
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
// Invitations - for self-hosted deployments
DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
// SSO Configuration (for script-based registration)
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type
@@ -337,6 +340,7 @@ export const env = createEnv({
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
},
@@ -368,6 +372,7 @@ export const env = createEnv({
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: process.env.NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED,
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED,
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED,
NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS,
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,

View File

@@ -103,6 +103,12 @@ export const isOrganizationsEnabled =
*/
export const isE2bEnabled = isTruthy(env.E2B_ENABLED)
/**
* Are invitations disabled globally
* When true, workspace invitations are disabled for all users
*/
export const isInvitationsDisabled = isTruthy(env.DISABLE_INVITATIONS)
/**
* Get cost multiplier based on environment
*/

View File

@@ -11,6 +11,7 @@ export interface PermissionGroupConfig {
disableMcpTools: boolean
disableCustomTools: boolean
hideTemplates: boolean
disableInvitations: boolean
}
export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
@@ -25,6 +26,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
disableMcpTools: false,
disableCustomTools: false,
hideTemplates: false,
disableInvitations: false,
}
export function parsePermissionGroupConfig(config: unknown): PermissionGroupConfig {
@@ -47,5 +49,6 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
disableMcpTools: typeof c.disableMcpTools === 'boolean' ? c.disableMcpTools : false,
disableCustomTools: typeof c.disableCustomTools === 'boolean' ? c.disableCustomTools : false,
hideTemplates: typeof c.hideTemplates === 'boolean' ? c.hideTemplates : false,
disableInvitations: typeof c.disableInvitations === 'boolean' ? c.disableInvitations : false,
}
}

View File

@@ -25,7 +25,6 @@ export interface WebhookProcessorOptions {
requestId: string
path?: string
webhookId?: string
testMode?: boolean
executionTarget?: 'deployed' | 'live'
}
@@ -733,17 +732,12 @@ export async function verifyProviderAuth(
/**
* Run preprocessing checks for webhook execution
* This replaces the old checkRateLimits and checkUsageLimits functions
*
* @param isTestMode - If true, skips deployment check (for test webhooks that run on live/draft state)
*/
export async function checkWebhookPreprocessing(
foundWorkflow: any,
foundWebhook: any,
requestId: string,
options?: { isTestMode?: boolean }
requestId: string
): Promise<NextResponse | null> {
const { isTestMode = false } = options || {}
try {
const executionId = uuidv4()
@@ -753,8 +747,8 @@ export async function checkWebhookPreprocessing(
triggerType: 'webhook',
executionId,
requestId,
checkRateLimit: true, // Webhooks need rate limiting
checkDeployment: !isTestMode, // Test webhooks skip deployment check (run on live state)
checkRateLimit: true,
checkDeployment: true,
workspaceId: foundWorkflow.workspaceId,
})
@@ -954,7 +948,6 @@ export async function queueWebhookExecution(
headers,
path: options.path || foundWebhook.path,
blockId: foundWebhook.blockId,
testMode: options.testMode,
executionTarget: options.executionTarget,
...(credentialId ? { credentialId } : {}),
}
@@ -962,18 +955,14 @@ export async function queueWebhookExecution(
if (isTriggerDevEnabled) {
const handle = await tasks.trigger('webhook-execution', payload)
logger.info(
`[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${
handle.id
} for ${foundWebhook.provider} webhook`
`[${options.requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook`
)
} else {
void executeWebhookJob(payload).catch((error) => {
logger.error(`[${options.requestId}] Direct webhook execution failed`, error)
})
logger.info(
`[${options.requestId}] Queued direct ${
options.testMode ? 'TEST ' : ''
}webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
`[${options.requestId}] Queued direct webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
)
}

View File

@@ -9,6 +9,7 @@ const airtableLogger = createLogger('AirtableWebhook')
const typeformLogger = createLogger('TypeformWebhook')
const calendlyLogger = createLogger('CalendlyWebhook')
const grainLogger = createLogger('GrainWebhook')
const lemlistLogger = createLogger('LemlistWebhook')
function getProviderConfig(webhook: any): Record<string, any> {
return (webhook.providerConfig as Record<string, any>) || {}
@@ -711,9 +712,58 @@ export async function deleteGrainWebhook(webhook: any, requestId: string): Promi
}
}
/**
* Delete a Lemlist webhook
* Don't fail webhook deletion if cleanup fails
*/
export async function deleteLemlistWebhook(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) {
lemlistLogger.warn(
`[${requestId}] Missing apiKey for Lemlist webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
if (!externalId) {
lemlistLogger.warn(
`[${requestId}] Missing externalId for Lemlist webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
// Lemlist uses Basic Auth with empty username and API key as password
const authString = Buffer.from(`:${apiKey}`).toString('base64')
const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${externalId}`
const lemlistResponse = await fetch(lemlistApiUrl, {
method: 'DELETE',
headers: {
Authorization: `Basic ${authString}`,
},
})
if (!lemlistResponse.ok && lemlistResponse.status !== 404) {
const responseBody = await lemlistResponse.json().catch(() => ({}))
lemlistLogger.warn(
`[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`,
{ response: responseBody }
)
} else {
lemlistLogger.info(`[${requestId}] Successfully deleted Lemlist webhook ${externalId}`)
}
} catch (error) {
lemlistLogger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error)
}
}
/**
* Clean up external webhook subscriptions for a webhook
* Handles Airtable, Teams, Telegram, Typeform, Calendly, and Grain cleanup
* Handles Airtable, Teams, Telegram, Typeform, Calendly, Grain, and Lemlist cleanup
* Don't fail deletion if cleanup fails
*/
export async function cleanupExternalWebhook(
@@ -733,5 +783,7 @@ export async function cleanupExternalWebhook(
await deleteCalendlyWebhook(webhook, requestId)
} else if (webhook.provider === 'grain') {
await deleteGrainWebhook(webhook, requestId)
} else if (webhook.provider === 'lemlist') {
await deleteLemlistWebhook(webhook, requestId)
}
}

View File

@@ -1,48 +0,0 @@
import { jwtVerify, SignJWT } from 'jose'
import { env } from '@/lib/core/config/env'
type TestTokenPayload = {
typ: 'webhook_test'
wid: string
}
const getSecretKey = () => new TextEncoder().encode(env.INTERNAL_API_SECRET)
export async function signTestWebhookToken(webhookId: string, ttlSeconds: number): Promise<string> {
const secret = getSecretKey()
const payload: TestTokenPayload = { typ: 'webhook_test', wid: webhookId }
const token = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${ttlSeconds}s`)
.setIssuer('sim-webhooks')
.setAudience('sim-test')
.sign(secret)
return token
}
export async function verifyTestWebhookToken(
token: string,
expectedWebhookId: string
): Promise<boolean> {
try {
const secret = getSecretKey()
const { payload } = await jwtVerify(token, secret, {
issuer: 'sim-webhooks',
audience: 'sim-test',
})
if (
payload &&
(payload as any).typ === 'webhook_test' &&
(payload as any).wid === expectedWebhookId
) {
return true
}
return false
} catch (_e) {
return false
}
}

View File

@@ -2340,62 +2340,6 @@ describe('hasWorkflowChanged', () => {
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent('should not detect change when testUrl differs', () => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
testUrl: { value: null },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
testUrl: { value: 'https://test.example.com/webhook' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent('should not detect change when testUrlExpiresAt differs', () => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
testUrlExpiresAt: { value: null },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
testUrlExpiresAt: { value: '2025-12-31T23:59:59Z' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent('should not detect change when all runtime metadata differs', () => {
const deployedState = createWorkflowState({
blocks: {
@@ -2405,8 +2349,6 @@ describe('hasWorkflowChanged', () => {
triggerConfig: { value: { event: 'push' } },
webhookId: { value: null },
triggerPath: { value: '' },
testUrl: { value: null },
testUrlExpiresAt: { value: null },
},
}),
},
@@ -2420,8 +2362,6 @@ describe('hasWorkflowChanged', () => {
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_123456' },
triggerPath: { value: '/api/webhooks/abc123' },
testUrl: { value: 'https://test.example.com/webhook' },
testUrlExpiresAt: { value: '2025-12-31T23:59:59Z' },
},
}),
},

View File

@@ -0,0 +1,131 @@
import type {
LemlistGetActivitiesParams,
LemlistGetActivitiesResponse,
} from '@/tools/lemlist/types'
import type { ToolConfig } from '@/tools/types'
export const getActivitiesTool: ToolConfig<
LemlistGetActivitiesParams,
LemlistGetActivitiesResponse
> = {
id: 'lemlist_get_activities',
name: 'Lemlist Get Activities',
description:
'Retrieves campaign activities and steps performed, including email opens, clicks, replies, and other events.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Lemlist API key',
},
type: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Filter by activity type (e.g., emailOpened, emailClicked, emailReplied, paused)',
},
campaignId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by campaign ID',
},
leadId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by lead ID',
},
isFirst: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Filter for first activity only',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results per request (max 100, default 100)',
},
offset: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of records to skip for pagination',
},
},
request: {
url: (params) => {
const url = new URL('https://api.lemlist.com/api/activities')
url.searchParams.append('version', 'v2')
if (params.type) url.searchParams.append('type', params.type)
if (params.campaignId) url.searchParams.append('campaignId', params.campaignId)
if (params.leadId) url.searchParams.append('leadId', params.leadId)
if (params.isFirst !== undefined) url.searchParams.append('isFirst', String(params.isFirst))
if (params.limit !== undefined) url.searchParams.append('limit', String(params.limit))
if (params.offset !== undefined) url.searchParams.append('offset', String(params.offset))
return url.toString()
},
method: 'GET',
headers: (params) => {
const credentials = Buffer.from(`:${params.apiKey}`).toString('base64')
return {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/json',
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const activities = Array.isArray(data) ? data : []
return {
success: true,
output: {
activities: activities.map((activity: Record<string, unknown>) => ({
_id: (activity._id as string) ?? '',
type: (activity.type as string) ?? '',
leadId: (activity.leadId as string) ?? '',
campaignId: (activity.campaignId as string) ?? '',
sequenceId: (activity.sequenceId as string) ?? null,
stepId: (activity.stepId as string) ?? null,
createdAt: (activity.createdAt as string) ?? '',
})),
count: activities.length,
},
}
},
outputs: {
activities: {
type: 'array',
description: 'List of activities',
items: {
type: 'object',
properties: {
_id: { type: 'string', description: 'Activity ID' },
type: { type: 'string', description: 'Activity type' },
leadId: { type: 'string', description: 'Associated lead ID' },
campaignId: { type: 'string', description: 'Campaign ID' },
sequenceId: { type: 'string', description: 'Sequence ID', optional: true },
stepId: { type: 'string', description: 'Step ID', optional: true },
createdAt: { type: 'string', description: 'When the activity occurred' },
},
},
},
count: {
type: 'number',
description: 'Number of activities returned',
},
},
}

View File

@@ -0,0 +1,119 @@
import type { LemlistGetLeadParams, LemlistGetLeadResponse } from '@/tools/lemlist/types'
import type { ToolConfig } from '@/tools/types'
export const getLeadTool: ToolConfig<LemlistGetLeadParams, LemlistGetLeadResponse> = {
id: 'lemlist_get_lead',
name: 'Lemlist Get Lead',
description: 'Retrieves lead information by email address or lead ID.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Lemlist API key',
},
leadIdentifier: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Lead email address or lead ID',
},
},
request: {
url: (params) => {
const identifier = params.leadIdentifier || ''
const isEmail = identifier.includes('@')
if (isEmail) {
return `https://api.lemlist.com/api/leads/${encodeURIComponent(identifier)}`
}
return `https://api.lemlist.com/api/leads?id=${encodeURIComponent(identifier)}`
},
method: 'GET',
headers: (params) => {
const credentials = Buffer.from(`:${params.apiKey}`).toString('base64')
return {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/json',
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
_id: data._id ?? '',
email: data.email ?? '',
firstName: data.firstName ?? null,
lastName: data.lastName ?? null,
companyName: data.companyName ?? null,
jobTitle: data.jobTitle ?? null,
companyDomain: data.companyDomain ?? null,
isPaused: data.isPaused ?? false,
campaignId: data.campaignId ?? null,
contactId: data.contactId ?? null,
emailStatus: data.emailStatus ?? null,
},
}
},
outputs: {
_id: {
type: 'string',
description: 'Lead ID',
},
email: {
type: 'string',
description: 'Lead email address',
},
firstName: {
type: 'string',
description: 'Lead first name',
optional: true,
},
lastName: {
type: 'string',
description: 'Lead last name',
optional: true,
},
companyName: {
type: 'string',
description: 'Company name',
optional: true,
},
jobTitle: {
type: 'string',
description: 'Job title',
optional: true,
},
companyDomain: {
type: 'string',
description: 'Company domain',
optional: true,
},
isPaused: {
type: 'boolean',
description: 'Whether the lead is paused',
},
campaignId: {
type: 'string',
description: 'Campaign ID the lead belongs to',
optional: true,
},
contactId: {
type: 'string',
description: 'Contact ID',
optional: true,
},
emailStatus: {
type: 'string',
description: 'Email deliverability status',
optional: true,
},
},
}

View File

@@ -0,0 +1,4 @@
export { getActivitiesTool as lemlistGetActivitiesTool } from '@/tools/lemlist/get_activities'
export { getLeadTool as lemlistGetLeadTool } from '@/tools/lemlist/get_lead'
export { sendEmailTool as lemlistSendEmailTool } from '@/tools/lemlist/send_email'
export * from './types'

View File

@@ -0,0 +1,106 @@
import type { LemlistSendEmailParams, LemlistSendEmailResponse } from '@/tools/lemlist/types'
import type { ToolConfig } from '@/tools/types'
export const sendEmailTool: ToolConfig<LemlistSendEmailParams, LemlistSendEmailResponse> = {
id: 'lemlist_send_email',
name: 'Lemlist Send Email',
description: 'Sends an email to a contact through the Lemlist inbox.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Lemlist API key',
},
sendUserId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Identifier for the user sending the message',
},
sendUserEmail: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email address of the sender',
},
sendUserMailboxId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Mailbox identifier for the sender',
},
contactId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Recipient contact identifier',
},
leadId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Associated lead identifier',
},
subject: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email subject line',
},
message: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email message body in HTML format',
},
cc: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of CC email addresses',
},
},
request: {
url: () => 'https://api.lemlist.com/api/inbox/email',
method: 'POST',
headers: (params) => {
const credentials = Buffer.from(`:${params.apiKey}`).toString('base64')
return {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/json',
}
},
body: (params) => ({
sendUserId: params.sendUserId?.trim(),
sendUserEmail: params.sendUserEmail?.trim(),
sendUserMailboxId: params.sendUserMailboxId?.trim(),
contactId: params.contactId?.trim(),
leadId: params.leadId?.trim(),
subject: params.subject?.trim(),
message: params.message,
cc: params.cc ?? [],
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
ok: data.ok ?? true,
},
}
},
outputs: {
ok: {
type: 'boolean',
description: 'Whether the email was sent successfully',
},
},
}

View File

@@ -0,0 +1,75 @@
import type { ToolResponse } from '@/tools/types'
export interface LemlistBaseParams {
apiKey: string
}
export interface LemlistGetActivitiesParams extends LemlistBaseParams {
type?: string
campaignId?: string
leadId?: string
isFirst?: boolean
limit?: number
offset?: number
}
export interface LemlistActivity {
_id: string
type: string
leadId: string
campaignId: string
sequenceId: string | null
stepId: string | null
createdAt: string
}
export interface LemlistGetActivitiesResponse extends ToolResponse {
output: {
activities: LemlistActivity[]
count: number
}
}
export interface LemlistGetLeadParams extends LemlistBaseParams {
leadIdentifier: string
}
export interface LemlistLead {
_id: string
email: string
firstName: string | null
lastName: string | null
companyName: string | null
jobTitle: string | null
companyDomain: string | null
isPaused: boolean
campaignId: string | null
contactId: string | null
emailStatus: string | null
}
export interface LemlistGetLeadResponse extends ToolResponse {
output: LemlistLead
}
export interface LemlistSendEmailParams extends LemlistBaseParams {
sendUserId: string
sendUserEmail: string
sendUserMailboxId: string
contactId: string
leadId: string
subject: string
message: string
cc?: string[]
}
export interface LemlistSendEmailResponse extends ToolResponse {
output: {
ok: boolean
}
}
export type LemlistResponse =
| LemlistGetActivitiesResponse
| LemlistGetLeadResponse
| LemlistSendEmailResponse

View File

@@ -643,6 +643,7 @@ import {
knowledgeSearchTool,
knowledgeUploadChunkTool,
} from '@/tools/knowledge'
import { lemlistGetActivitiesTool, lemlistGetLeadTool, lemlistSendEmailTool } from '@/tools/lemlist'
import {
linearAddLabelToIssueTool,
linearAddLabelToProjectTool,
@@ -2416,6 +2417,9 @@ export const tools: Record<string, ToolConfig> = {
linear_update_project_status: linearUpdateProjectStatusTool,
linear_delete_project_status: linearDeleteProjectStatusTool,
linear_list_project_statuses: linearListProjectStatusesTool,
lemlist_get_activities: lemlistGetActivitiesTool,
lemlist_get_lead: lemlistGetLeadTool,
lemlist_send_email: lemlistSendEmailTool,
shopify_create_product: shopifyCreateProductTool,
shopify_get_product: shopifyGetProductTool,
shopify_list_products: shopifyListProductsTool,

View File

@@ -26,20 +26,13 @@ export const TRIGGER_PERSISTED_SUBBLOCK_IDS: string[] = [
'selectedTriggerId',
'webhookId',
'triggerPath',
'testUrl',
'testUrlExpiresAt',
]
/**
* Trigger-related subblock IDs that represent runtime metadata. They should remain
* in the workflow state but must not be modified or cleared by diff operations.
*/
export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [
'webhookId',
'triggerPath',
'testUrl',
'testUrlExpiresAt',
]
export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = ['webhookId', 'triggerPath']
/**
* Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled.

View File

@@ -66,3 +66,115 @@ export function isTriggerValid(triggerId: string): boolean {
}
export type { TriggerConfig, TriggerRegistry } from '@/triggers/types'
/**
* Options for building trigger subBlocks
*/
export interface BuildTriggerSubBlocksOptions {
/** The trigger ID (e.g., 'lemlist_email_replied') */
triggerId: string
/** Dropdown options for selecting trigger type */
triggerOptions: Array<{ label: string; id: string }>
/** Whether to include the trigger type dropdown (only for primary trigger) */
includeDropdown?: boolean
/** HTML setup instructions to display */
setupInstructions: string
/** Additional fields to insert before the save button (e.g., campaign filters) */
extraFields?: SubBlockConfig[]
/** Webhook URL placeholder text */
webhookPlaceholder?: string
}
/**
* Generic builder for trigger subBlocks.
* Creates a consistent structure: [dropdown?] -> webhookUrl -> extraFields -> save -> instructions
*
* Usage:
* - Primary trigger: `buildTriggerSubBlocks({ ...options, includeDropdown: true })`
* - Secondary triggers: `buildTriggerSubBlocks({ ...options })` (no dropdown)
*
* @example
* ```typescript
* // Primary trigger (with dropdown)
* subBlocks: buildTriggerSubBlocks({
* triggerId: 'service_event_a',
* triggerOptions: serviceTriggerOptions,
* includeDropdown: true,
* setupInstructions: serviceSetupInstructions('eventA'),
* })
*
* // Secondary trigger (no dropdown)
* subBlocks: buildTriggerSubBlocks({
* triggerId: 'service_event_b',
* triggerOptions: serviceTriggerOptions,
* setupInstructions: serviceSetupInstructions('eventB'),
* })
* ```
*/
export function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[] {
const {
triggerId,
triggerOptions,
includeDropdown = false,
setupInstructions,
extraFields = [],
webhookPlaceholder = 'Webhook URL will be generated',
} = options
const blocks: SubBlockConfig[] = []
// Only the primary trigger includes the dropdown
if (includeDropdown) {
blocks.push({
id: 'selectedTriggerId',
title: 'Trigger Type',
type: 'dropdown',
mode: 'trigger',
options: triggerOptions,
value: () => triggerId,
required: true,
})
}
// Webhook URL display (common to all triggers)
blocks.push({
id: 'webhookUrlDisplay',
title: 'Webhook URL',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: webhookPlaceholder,
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
})
// Insert any extra fields (campaign filters, event types, etc.)
if (extraFields.length > 0) {
blocks.push(...extraFields)
}
// Save button
blocks.push({
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: triggerId,
condition: { field: 'selectedTriggerId', value: triggerId },
})
// Setup instructions
blocks.push({
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: setupInstructions,
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
})
return blocks
}

View File

@@ -0,0 +1,38 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Lemlist Email Bounced Trigger
* Triggers when an email bounces in a Lemlist campaign
*/
export const lemlistEmailBouncedTrigger: TriggerConfig = {
id: 'lemlist_email_bounced',
name: 'Lemlist Email Bounced',
provider: 'lemlist',
description: 'Trigger workflow when an email bounces',
version: '1.0.0',
icon: LemlistIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'lemlist_email_bounced',
triggerOptions: lemlistTriggerOptions,
setupInstructions: lemlistSetupInstructions('emailsBounced'),
extraFields: buildLemlistExtraFields('lemlist_email_bounced'),
}),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,38 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Lemlist Email Clicked Trigger
* Triggers when a lead clicks a link in an email
*/
export const lemlistEmailClickedTrigger: TriggerConfig = {
id: 'lemlist_email_clicked',
name: 'Lemlist Email Clicked',
provider: 'lemlist',
description: 'Trigger workflow when a lead clicks a link in an email',
version: '1.0.0',
icon: LemlistIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'lemlist_email_clicked',
triggerOptions: lemlistTriggerOptions,
setupInstructions: lemlistSetupInstructions('emailsClicked'),
extraFields: buildLemlistExtraFields('lemlist_email_clicked'),
}),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,38 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Lemlist Email Opened Trigger
* Triggers when a lead opens an email in a Lemlist campaign
*/
export const lemlistEmailOpenedTrigger: TriggerConfig = {
id: 'lemlist_email_opened',
name: 'Lemlist Email Opened',
provider: 'lemlist',
description: 'Trigger workflow when a lead opens an email',
version: '1.0.0',
icon: LemlistIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'lemlist_email_opened',
triggerOptions: lemlistTriggerOptions,
setupInstructions: lemlistSetupInstructions('emailsOpened'),
extraFields: buildLemlistExtraFields('lemlist_email_opened'),
}),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,41 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildEmailReplyOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Lemlist Email Replied Trigger
* Triggers when a lead replies to an email in a Lemlist campaign
*
* This is the PRIMARY trigger - it includes the dropdown for selecting trigger type.
*/
export const lemlistEmailRepliedTrigger: TriggerConfig = {
id: 'lemlist_email_replied',
name: 'Lemlist Email Replied',
provider: 'lemlist',
description: 'Trigger workflow when a lead replies to an email',
version: '1.0.0',
icon: LemlistIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'lemlist_email_replied',
triggerOptions: lemlistTriggerOptions,
includeDropdown: true,
setupInstructions: lemlistSetupInstructions('emailsReplied'),
extraFields: buildLemlistExtraFields('lemlist_email_replied'),
}),
outputs: buildEmailReplyOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,38 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Lemlist Email Sent Trigger
* Triggers when an email is sent in a Lemlist campaign
*/
export const lemlistEmailSentTrigger: TriggerConfig = {
id: 'lemlist_email_sent',
name: 'Lemlist Email Sent',
provider: 'lemlist',
description: 'Trigger workflow when an email is sent',
version: '1.0.0',
icon: LemlistIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'lemlist_email_sent',
triggerOptions: lemlistTriggerOptions,
setupInstructions: lemlistSetupInstructions('emailsSent'),
extraFields: buildLemlistExtraFields('lemlist_email_sent'),
}),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,14 @@
/**
* Lemlist Triggers
* Export all Lemlist webhook triggers
*/
export { lemlistEmailBouncedTrigger } from './email_bounced'
export { lemlistEmailClickedTrigger } from './email_clicked'
export { lemlistEmailOpenedTrigger } from './email_opened'
export { lemlistEmailRepliedTrigger } from './email_replied'
export { lemlistEmailSentTrigger } from './email_sent'
export { lemlistInterestedTrigger } from './interested'
export { lemlistLinkedInRepliedTrigger } from './linkedin_replied'
export { lemlistNotInterestedTrigger } from './not_interested'
export { lemlistWebhookTrigger } from './webhook'

View File

@@ -0,0 +1,38 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Lemlist Interested Trigger
* Triggers when a lead is marked as interested in a Lemlist campaign
*/
export const lemlistInterestedTrigger: TriggerConfig = {
id: 'lemlist_interested',
name: 'Lemlist Lead Interested',
provider: 'lemlist',
description: 'Trigger workflow when a lead is marked as interested',
version: '1.0.0',
icon: LemlistIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'lemlist_interested',
triggerOptions: lemlistTriggerOptions,
setupInstructions: lemlistSetupInstructions('interested'),
extraFields: buildLemlistExtraFields('lemlist_interested'),
}),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,38 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildLemlistExtraFields,
buildLinkedInReplyOutputs,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Lemlist LinkedIn Replied Trigger
* Triggers when a lead replies to a LinkedIn message in a Lemlist campaign
*/
export const lemlistLinkedInRepliedTrigger: TriggerConfig = {
id: 'lemlist_linkedin_replied',
name: 'Lemlist LinkedIn Replied',
provider: 'lemlist',
description: 'Trigger workflow when a lead replies to a LinkedIn message',
version: '1.0.0',
icon: LemlistIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'lemlist_linkedin_replied',
triggerOptions: lemlistTriggerOptions,
setupInstructions: lemlistSetupInstructions('linkedinReplied'),
extraFields: buildLemlistExtraFields('lemlist_linkedin_replied'),
}),
outputs: buildLinkedInReplyOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,38 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Lemlist Not Interested Trigger
* Triggers when a lead is marked as not interested in a Lemlist campaign
*/
export const lemlistNotInterestedTrigger: TriggerConfig = {
id: 'lemlist_not_interested',
name: 'Lemlist Lead Not Interested',
provider: 'lemlist',
description: 'Trigger workflow when a lead is marked as not interested',
version: '1.0.0',
icon: LemlistIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'lemlist_not_interested',
triggerOptions: lemlistTriggerOptions,
setupInstructions: lemlistSetupInstructions('notInterested'),
extraFields: buildLemlistExtraFields('lemlist_not_interested'),
}),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,268 @@
import type { TriggerOutput } from '@/triggers/types'
/**
* Shared trigger dropdown options for all Lemlist triggers
*/
export const lemlistTriggerOptions = [
{ label: 'Email Replied', id: 'lemlist_email_replied' },
{ label: 'LinkedIn Replied', id: 'lemlist_linkedin_replied' },
{ label: 'Lead Interested', id: 'lemlist_interested' },
{ label: 'Lead Not Interested', id: 'lemlist_not_interested' },
{ label: 'Email Opened', id: 'lemlist_email_opened' },
{ label: 'Email Clicked', id: 'lemlist_email_clicked' },
{ label: 'Email Bounced', id: 'lemlist_email_bounced' },
{ label: 'Email Sent', id: 'lemlist_email_sent' },
{ label: 'Generic Webhook (All Events)', id: 'lemlist_webhook' },
]
/**
* Generates setup instructions for Lemlist webhooks
* The webhook is automatically created in Lemlist when you save
*/
export function lemlistSetupInstructions(eventType: string): string {
const instructions = [
'Enter your Lemlist API Key above.',
'You can find your API key in Lemlist at <strong>Settings > Integrations > API</strong>.',
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in Lemlist for <strong>${eventType}</strong> events.`,
'The webhook will be automatically deleted when you remove this trigger.',
]
return instructions
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join('')
}
/**
* Helper to build Lemlist-specific extra fields.
* Includes API key (required) and optional campaign filter.
* Use with the generic buildTriggerSubBlocks from @/triggers.
*/
export function buildLemlistExtraFields(triggerId: string) {
return [
{
id: 'apiKey',
title: 'API Key',
type: 'short-input' as const,
placeholder: 'Enter your Lemlist API key',
description: 'Required to create the webhook in Lemlist.',
password: true,
required: true,
mode: 'trigger' as const,
condition: { field: 'selectedTriggerId', value: triggerId },
},
{
id: 'campaignId',
title: 'Campaign ID (Optional)',
type: 'short-input' as const,
placeholder: 'cam_xxxxx (leave empty for all campaigns)',
description: 'Optionally scope the webhook to a specific campaign',
mode: 'trigger' as const,
condition: { field: 'selectedTriggerId', value: triggerId },
},
]
}
/**
* Base activity outputs shared across all Lemlist triggers
*/
function buildBaseActivityOutputs(): Record<string, TriggerOutput> {
return {
type: {
type: 'string',
description: 'Activity type (emailsReplied, linkedinReplied, interested, emailsOpened, etc.)',
},
_id: {
type: 'string',
description: 'Unique activity identifier',
},
leadId: {
type: 'string',
description: 'Associated lead ID',
},
campaignId: {
type: 'string',
description: 'Campaign ID',
},
campaignName: {
type: 'string',
description: 'Campaign name',
},
sequenceId: {
type: 'string',
description: 'Sequence ID within the campaign',
},
stepId: {
type: 'string',
description: 'Step ID that triggered this activity',
},
createdAt: {
type: 'string',
description: 'When the activity occurred (ISO 8601)',
},
}
}
/**
* Lead outputs - information about the lead
*/
function buildLeadOutputs(): Record<string, TriggerOutput> {
return {
lead: {
_id: {
type: 'string',
description: 'Lead unique identifier',
},
email: {
type: 'string',
description: 'Lead email address',
},
firstName: {
type: 'string',
description: 'Lead first name',
},
lastName: {
type: 'string',
description: 'Lead last name',
},
companyName: {
type: 'string',
description: 'Lead company name',
},
phone: {
type: 'string',
description: 'Lead phone number',
},
linkedinUrl: {
type: 'string',
description: 'Lead LinkedIn profile URL',
},
picture: {
type: 'string',
description: 'Lead profile picture URL',
},
icebreaker: {
type: 'string',
description: 'Personalized icebreaker text',
},
timezone: {
type: 'string',
description: 'Lead timezone (e.g., America/New_York)',
},
isUnsubscribed: {
type: 'boolean',
description: 'Whether the lead is unsubscribed',
},
},
}
}
/**
* Standard activity outputs (activity + lead data)
*/
export function buildActivityOutputs(): Record<string, TriggerOutput> {
return {
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
webhook: {
type: 'json',
description: 'Full webhook payload with all activity-specific data',
},
}
}
/**
* Email-specific outputs (includes message content for replies)
*/
export function buildEmailReplyOutputs(): Record<string, TriggerOutput> {
return {
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
messageId: {
type: 'string',
description: 'Email message ID',
},
subject: {
type: 'string',
description: 'Email subject line',
},
text: {
type: 'string',
description: 'Email reply text content',
},
html: {
type: 'string',
description: 'Email reply HTML content',
},
sentAt: {
type: 'string',
description: 'When the reply was sent',
},
webhook: {
type: 'json',
description: 'Full webhook payload with all email data',
},
}
}
/**
* LinkedIn-specific outputs (includes message content)
*/
export function buildLinkedInReplyOutputs(): Record<string, TriggerOutput> {
return {
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
messageId: {
type: 'string',
description: 'LinkedIn message ID',
},
text: {
type: 'string',
description: 'LinkedIn message text content',
},
sentAt: {
type: 'string',
description: 'When the message was sent',
},
webhook: {
type: 'json',
description: 'Full webhook payload with all LinkedIn data',
},
}
}
/**
* All outputs for generic webhook (activity + lead + all possible fields)
*/
export function buildAllOutputs(): Record<string, TriggerOutput> {
return {
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
messageId: {
type: 'string',
description: 'Message ID (for email/LinkedIn events)',
},
subject: {
type: 'string',
description: 'Email subject (for email events)',
},
text: {
type: 'string',
description: 'Message text content',
},
html: {
type: 'string',
description: 'Message HTML content (for email events)',
},
sentAt: {
type: 'string',
description: 'When the message was sent',
},
webhook: {
type: 'json',
description: 'Full webhook payload with all data',
},
}
}

View File

@@ -0,0 +1,38 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildAllOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Generic Lemlist Webhook Trigger
* Captures all Lemlist webhook events with optional filtering
*/
export const lemlistWebhookTrigger: TriggerConfig = {
id: 'lemlist_webhook',
name: 'Lemlist Webhook (All Events)',
provider: 'lemlist',
description: 'Trigger workflow on any Lemlist webhook event',
version: '1.0.0',
icon: LemlistIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'lemlist_webhook',
triggerOptions: lemlistTriggerOptions,
setupInstructions: lemlistSetupInstructions('All Events (no type filter)'),
extraFields: buildLemlistExtraFields('lemlist_webhook'),
}),
outputs: buildAllOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -65,6 +65,17 @@ import {
jiraWebhookTrigger,
jiraWorklogCreatedTrigger,
} from '@/triggers/jira'
import {
lemlistEmailBouncedTrigger,
lemlistEmailClickedTrigger,
lemlistEmailOpenedTrigger,
lemlistEmailRepliedTrigger,
lemlistEmailSentTrigger,
lemlistInterestedTrigger,
lemlistLinkedInRepliedTrigger,
lemlistNotInterestedTrigger,
lemlistWebhookTrigger,
} from '@/triggers/lemlist'
import {
linearCommentCreatedTrigger,
linearCommentUpdatedTrigger,
@@ -139,6 +150,15 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
jira_issue_deleted: jiraIssueDeletedTrigger,
jira_issue_commented: jiraIssueCommentedTrigger,
jira_worklog_created: jiraWorklogCreatedTrigger,
lemlist_webhook: lemlistWebhookTrigger,
lemlist_email_replied: lemlistEmailRepliedTrigger,
lemlist_email_opened: lemlistEmailOpenedTrigger,
lemlist_email_clicked: lemlistEmailClickedTrigger,
lemlist_email_sent: lemlistEmailSentTrigger,
lemlist_email_bounced: lemlistEmailBouncedTrigger,
lemlist_linkedin_replied: lemlistLinkedInRepliedTrigger,
lemlist_interested: lemlistInterestedTrigger,
lemlist_not_interested: lemlistNotInterestedTrigger,
linear_webhook: linearWebhookTrigger,
linear_issue_created: linearIssueCreatedTrigger,
linear_issue_updated: linearIssueUpdatedTrigger,

View File

@@ -147,6 +147,10 @@ app:
BLACKLISTED_PROVIDERS: "" # Comma-separated provider IDs to hide from UI (e.g., "openai,anthropic,google")
BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
# Invitation Control
DISABLE_INVITATIONS: "" # Set to "true" to disable workspace invitations globally
NEXT_PUBLIC_DISABLE_INVITATIONS: "" # Set to "true" to hide invitation UI elements
# SSO Configuration (Enterprise Single Sign-On)
# Set to "true" AFTER running the SSO registration script
SSO_ENABLED: "" # Enable SSO authentication ("true" to enable)