Compare commits

..

30 Commits

Author SHA1 Message Date
Emir Karabeg
602a3c2e57 refactor(action-bar): renamed to workflow controls 2026-01-14 10:13:21 -08:00
Emir Karabeg
9dfd5c4b83 improvement(action-bar): ui/ux 2026-01-14 10:07:11 -08:00
Emir Karabeg
722f35f1d1 refactor: popovers; improvement: notifications, diff controls, action bar 2026-01-14 10:07:10 -08:00
Emir Karabeg
cb084a155d fix: duplicate fit to view 2026-01-14 10:06:11 -08:00
Emir Karabeg
32677c3e1d fix(theme): dark mode flash 2026-01-14 10:05:48 -08:00
Emir Karabeg
d1ee5c33ac improvement(ui/ux): action bar, panel, tooltip, dragging, invite modal 2026-01-14 10:05:46 -08:00
waleed
143e3d9d84 remove dead state variable, add logs 2026-01-14 10:03:27 -08:00
waleed
b8c07e50de ack PR comments 2026-01-14 10:03:26 -08:00
waleed
2568d7ea4b added util for fit to zoom that accounts for sidebar, terminal, and panel 2026-01-14 10:02:25 -08:00
waleed
de65646484 feat(workflow-controls): added action bar for picker/hand/undo/redo/zoom workflow controls, added general setting to disable 2026-01-14 10:00:29 -08:00
Waleed
fca775123d 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-14 09:59:48 -08:00
Vikhyath Mondreti
0ffc575fb3 improvement(block-outputs): display metadata properties destructured (#2772)
* improvement(block-outputs):display metadata properties destructured

* add back icons

* fix google calendar

* reuse versioned tool selector

* fix null fields

* github optionality

* fix notion

* review stripe tools metadata

* fix optional tools + types

* fix docs type

* add db row tool + fix copilot versioning recognition
2026-01-14 09:59:48 -08:00
Vikhyath Mondreti
9ca86c86f2 improvement(byok): make available for all plans (#2782)
* improvement(byok): make available for all plans

* update docs

* address greptile comments
2026-01-14 09:59:48 -08:00
Siddharth Ganesan
a806b5ac38 feat(copilot): add context7 (#2779)
* Add context7

* Fix edit diff block ring color

* Remove server side impl

* Fix duplicated message on edit old message

* Tables in markdown
2026-01-14 09:59:48 -08:00
Waleed
91070c0cbd feat(tool): added introspection tools for all db integrations (#2780)
* feat(tool): added introspection tools for all db integrations

* added sanitization for sql schema

* ack pr comments, with clarifying comments

* moved util
2026-01-14 09:59:48 -08:00
Waleed
8a693438be feat(tools): added workflow tools to agent tools dropdown for discoverability, enforce perms on client for redeploying via the agent (#2778)
* feat(tools): added workflow tools to agent tools dropdown for discoverability, enforce perms on client for redeploying via the agent

* added perms enforcement to workflow block header as well
2026-01-14 09:59:48 -08:00
Waleed
4db28ed947 feat(export): added the ability to export workflow (#2777)
* feat(export): added the ability to export workflow

* improvement(import): loading animation

* fixed flicker on importing multiple workflows

* ack pr comments

* standardized import/export hooks

* upgraded turborepo

* cleaned up

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-14 09:59:48 -08:00
Waleed
8e856605e4 fix(color-picker): confirm color change before updating workflow color (#2776) 2026-01-14 09:59:47 -08:00
Waleed
76c3e1eb09 fix(resize): fix subflow resize on drag, children deselected in subflow on drag (#2771)
* fix(resize): fix subflow resize on drag, children deselected in subflow on drag

* ack PR comments

* fix copy-paste subflows deselecting children

* ack comments
2026-01-14 09:59:46 -08:00
Waleed
96ec6a43cd feat(popover): add expandOnHover, added the ability to change the color of a workflow icon, new workflow naming convention (#2770)
* feat(popover): add expandOnHover, added the ability to change the color of a workflow icon

* updated workflow naming conventions
2026-01-14 09:56:53 -08:00
Siddharth Ganesan
70fda2e70c fix(copilot): fix copilot chat loading (#2769)
* Fix loading

* Fix Lint

* Scroll stickiness

* Scroll stickiness

* improvement: diff controls and notifications positioning

* feat(copilot): editable input component

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-14 09:56:53 -08:00
Waleed
f9efccd9d9 fix(subflow): updated subflow border to match block border (#2768) 2026-01-14 09:56:52 -08:00
Waleed
58b70dacf5 fix(tag-input): add onInputChange to clear errors when new text is entered (#2765)
* fix(tag-input): add onInputChange to clear errors when new text is entered

* added paste case too
2026-01-14 09:55:56 -08:00
Waleed
d18c95dcdb fix(context-menu): make divider on context menu aware of available options (#2766) 2026-01-14 09:55:56 -08:00
waleed
14570e7651 added fit to view in canvas context menu 2026-01-11 20:18:42 -08:00
Emir Karabeg
06782a881e improvement(ui/ux): action bar, panel, tooltip, dragging, invite modal 2026-01-10 22:03:23 -08:00
waleed
75da0c3568 remove dead state variable, add logs 2026-01-10 16:39:05 -08:00
waleed
14a691ba8c ack PR comments 2026-01-10 16:06:51 -08:00
waleed
530e2ac212 added util for fit to zoom that accounts for sidebar, terminal, and panel 2026-01-10 15:07:50 -08:00
waleed
e59e2fdf2a feat(workflow-controls): added action bar for picker/hand/undo/redo/zoom workflow controls, added general setting to disable 2026-01-10 14:44:59 -08:00
312 changed files with 6309 additions and 28441 deletions

View File

@@ -1,591 +0,0 @@
---
description: Create a block configuration for a Sim 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. 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

@@ -1,450 +0,0 @@
---
description: Add a complete integration to Sim (tools, block, icon, registration)
argument-hint: <service-name> [api-docs-url]
---
# Add Integration Skill
You are an expert at adding complete integrations to Sim. 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

@@ -1,284 +0,0 @@
---
description: Create tool configurations for a Sim 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 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

@@ -1,708 +0,0 @@
---
description: Create webhook triggers for a Sim integration using the generic trigger builder
argument-hint: <service-name>
---
# Add Trigger Skill
You are an expert at creating webhook triggers for Sim. 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 & Webhook Input Formatting
### Important: Two Sources of Truth
There are two related but separate concerns:
1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown.
2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`.
**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ:
- Tag dropdown shows fields that don't exist (broken variable resolution)
- Or actual data has fields not shown in dropdown (users can't discover them)
### When to Add a formatWebhookInput Handler
- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly.
- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler.
### Adding a Handler
In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block:
```typescript
if (foundWebhook.provider === '{service}') {
// Transform raw webhook body to match trigger outputs
return {
eventType: body.type,
resourceId: body.data?.id || '',
timestamp: body.created_at,
resource: body.data,
}
}
```
**Key rules:**
- Return fields that match your trigger `outputs` definition exactly
- No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }`
- No duplication (don't spread body AND add individual fields)
- Use `null` for missing optional data, not empty objects with empty strings
### Verify Alignment
Run the alignment checker:
```bash
bunx scripts/check-trigger-alignment.ts {service}
```
## 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
### Webhook Input Formatting
- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed)
- [ ] Handler returns fields matching trigger `outputs` exactly
- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment
### 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

@@ -1,10 +1,11 @@
name: 'Auto-translate Documentation'
on:
schedule:
# Run every Sunday at midnight UTC
- cron: '0 0 * * 0'
workflow_dispatch: # Allow manual triggers
push:
branches: [ staging ]
paths:
- 'apps/docs/content/docs/en/**'
- 'apps/docs/i18n.json'
permissions:
contents: write
@@ -19,7 +20,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: staging
token: ${{ secrets.GH_PAT }}
fetch-depth: 0
@@ -68,11 +68,12 @@ jobs:
title: "feat(i18n): update translations"
body: |
## Summary
Automated weekly translation updates for documentation.
This PR was automatically created by the scheduled weekly i18n workflow, updating translations for all supported languages using Lingo.dev AI translation engine.
**Triggered**: Weekly scheduled run
Automated translation updates triggered by changes to documentation.
This PR was automatically created after content changes were made, updating translations for all supported languages using Lingo.dev AI translation engine.
**Original trigger**: ${{ github.event.head_commit.message }}
**Commit**: ${{ github.sha }}
**Workflow**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
## Type of Change
@@ -106,7 +107,7 @@ jobs:
## Screenshots/Videos
<!-- Translation changes are text-based - no visual changes expected -->
<!-- Reviewers should check the documentation site renders correctly for all languages -->
branch: auto-translate/weekly-${{ github.run_id }}
branch: auto-translate/staging-merge-${{ github.run_id }}
base: staging
labels: |
i18n
@@ -144,8 +145,6 @@ jobs:
bun install --frozen-lockfile
- name: Build documentation to verify translations
env:
DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy
run: |
cd apps/docs
bun run build
@@ -154,7 +153,7 @@ jobs:
run: |
cd apps/docs
echo "## Translation Status Report" >> $GITHUB_STEP_SUMMARY
echo "**Weekly scheduled translation run**" >> $GITHUB_STEP_SUMMARY
echo "**Triggered by merge to staging branch**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
en_count=$(find content/docs/en -name "*.mdx" | wc -l)

View File

@@ -38,41 +38,6 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Validate feature flags
run: |
FILE="apps/sim/lib/core/config/feature-flags.ts"
ERRORS=""
echo "Checking for hardcoded boolean feature flags..."
# Use perl for multiline matching to catch both:
# export const isHosted = true
# export const isHosted =
# true
HARDCODED=$(perl -0777 -ne 'while (/export const (is[A-Za-z]+)\s*=\s*\n?\s*(true|false)\b/g) { print " $1 = $2\n" }' "$FILE")
if [ -n "$HARDCODED" ]; then
ERRORS="${ERRORS}\n❌ Feature flags must not be hardcoded to boolean literals!\n\nFound hardcoded flags:\n${HARDCODED}\n\nFeature flags should derive their values from environment variables.\n"
fi
echo "Checking feature flag naming conventions..."
# Check that all export const (except functions) start with 'is'
# This finds exports like "export const someFlag" that don't start with "is" or "get"
BAD_NAMES=$(grep -E "^export const [a-z]" "$FILE" | grep -vE "^export const (is|get)" | sed 's/export const \([a-zA-Z]*\).*/ \1/')
if [ -n "$BAD_NAMES" ]; then
ERRORS="${ERRORS}\n❌ Feature flags must use 'is' prefix for boolean flags!\n\nFound incorrectly named flags:\n${BAD_NAMES}\n\nExample: 'hostedMode' should be 'isHostedMode'\n"
fi
if [ -n "$ERRORS" ]; then
echo ""
echo -e "$ERRORS"
exit 1
fi
echo "✅ All feature flags are properly configured"
- name: Lint code
run: bun run lint:check

View File

@@ -1,4 +1,4 @@
# Sim Development Guidelines
# Sim Studio Development Guidelines
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.

117
README.md
View File

@@ -13,10 +13,6 @@
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
</p>
<p align="center">
<a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
</p>
### Build Workflows with Ease
Design agent workflows visually on a canvas—connect agents, tools, and blocks, then run them instantly.
@@ -64,11 +60,17 @@ Docker must be installed and running on your machine.
### Self-hosted: Docker Compose
```bash
git clone https://github.com/simstudioai/sim.git && cd sim
# Clone the repository
git clone https://github.com/simstudioai/sim.git
# Navigate to the project directory
cd sim
# Start Sim
docker compose -f docker-compose.prod.yml up -d
```
Open [http://localhost:3000](http://localhost:3000)
Access the application at [http://localhost:3000/](http://localhost:3000/)
#### Using Local Models with Ollama
@@ -89,17 +91,33 @@ docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
#### Using an External Ollama Instance
If Ollama is running on your host machine, use `host.docker.internal` instead of `localhost`:
If you already have Ollama running on your host machine (outside Docker), you need to configure the `OLLAMA_URL` to use `host.docker.internal` instead of `localhost`:
```bash
# Docker Desktop (macOS/Windows)
OLLAMA_URL=http://host.docker.internal:11434 docker compose -f docker-compose.prod.yml up -d
# Linux (add extra_hosts or use host IP)
docker compose -f docker-compose.prod.yml up -d # Then set OLLAMA_URL to your host's IP
```
On Linux, use your host's IP address or add `extra_hosts: ["host.docker.internal:host-gateway"]` to the compose file.
**Why?** When running inside Docker, `localhost` refers to the container itself, not your host machine. `host.docker.internal` is a special DNS name that resolves to the host.
For Linux users, you can either:
- Use your host machine's actual IP address (e.g., `http://192.168.1.100:11434`)
- Add `extra_hosts: ["host.docker.internal:host-gateway"]` to the simstudio service in your compose file
#### Using vLLM
Sim supports [vLLM](https://docs.vllm.ai/) for self-hosted models. Set `VLLM_BASE_URL` and optionally `VLLM_API_KEY` in your environment.
Sim also supports [vLLM](https://docs.vllm.ai/) for self-hosted models with OpenAI-compatible API:
```bash
# Set these environment variables
VLLM_BASE_URL=http://your-vllm-server:8000
VLLM_API_KEY=your_optional_api_key # Only if your vLLM instance requires auth
```
When running with Docker, use `host.docker.internal` if vLLM is on your host machine (same as Ollama above).
### Self-hosted: Dev Containers
@@ -110,9 +128,14 @@ Sim supports [vLLM](https://docs.vllm.ai/) for self-hosted models. Set `VLLM_BAS
### Self-hosted: Manual Setup
**Requirements:** [Bun](https://bun.sh/), [Node.js](https://nodejs.org/) v20+, PostgreSQL 12+ with [pgvector](https://github.com/pgvector/pgvector)
**Requirements:**
- [Bun](https://bun.sh/) runtime
- [Node.js](https://nodejs.org/) v20+ (required for sandboxed code execution)
- PostgreSQL 12+ with [pgvector extension](https://github.com/pgvector/pgvector) (required for AI embeddings)
1. Clone and install:
**Note:** Sim uses vector embeddings for AI features like knowledge bases and semantic search, which requires the `pgvector` PostgreSQL extension.
1. Clone and install dependencies:
```bash
git clone https://github.com/simstudioai/sim.git
@@ -122,33 +145,75 @@ bun install
2. Set up PostgreSQL with pgvector:
You need PostgreSQL with the `vector` extension for embedding support. Choose one option:
**Option A: Using Docker (Recommended)**
```bash
docker run --name simstudio-db -e POSTGRES_PASSWORD=your_password -e POSTGRES_DB=simstudio -p 5432:5432 -d pgvector/pgvector:pg17
# Start PostgreSQL with pgvector extension
docker run --name simstudio-db \
-e POSTGRES_PASSWORD=your_password \
-e POSTGRES_DB=simstudio \
-p 5432:5432 -d \
pgvector/pgvector:pg17
```
Or install manually via the [pgvector guide](https://github.com/pgvector/pgvector#installation).
**Option B: Manual Installation**
- Install PostgreSQL 12+ and the pgvector extension
- See [pgvector installation guide](https://github.com/pgvector/pgvector#installation)
3. Configure environment:
3. Set up environment:
```bash
cp apps/sim/.env.example apps/sim/.env
cp packages/db/.env.example packages/db/.env
# Edit both .env files to set DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
cd apps/sim
cp .env.example .env # Configure with required variables (DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL)
```
4. Run migrations:
Update your `.env` file with the database URL:
```bash
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
```
4. Set up the database:
First, configure the database package environment:
```bash
cd packages/db
cp .env.example .env
```
Update your `packages/db/.env` file with the database URL:
```bash
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
```
Then run the migrations:
```bash
cd packages/db # Required so drizzle picks correct .env file
bunx drizzle-kit migrate --config=./drizzle.config.ts
```
5. Start the development servers:
**Recommended approach - run both servers together (from project root):**
```bash
cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
bun run dev:full
```
5. Start development servers:
This starts both the main Next.js application and the realtime socket server required for full functionality.
**Alternative - run servers separately:**
Next.js app (from project root):
```bash
bun run dev:full # Starts both Next.js app and realtime socket server
bun run dev
```
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
Realtime socket server (from `apps/sim` directory in a separate terminal):
```bash
cd apps/sim
bun run dev:sockets
```
## Copilot API Keys
@@ -159,7 +224,7 @@ Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
## Environment Variables
Key environment variables for self-hosted deployments. See [`.env.example`](apps/sim/.env.example) for defaults or [`env.ts`](apps/sim/lib/core/config/env.ts) for the full list.
Key environment variables for self-hosted deployments (see `apps/sim/.env.example` for full list):
| Variable | Required | Description |
|----------|----------|-------------|
@@ -167,9 +232,9 @@ Key environment variables for self-hosted deployments. See [`.env.example`](apps
| `BETTER_AUTH_SECRET` | Yes | Auth secret (`openssl rand -hex 32`) |
| `BETTER_AUTH_URL` | Yes | Your app URL (e.g., `http://localhost:3000`) |
| `NEXT_PUBLIC_APP_URL` | Yes | Public app URL (same as above) |
| `ENCRYPTION_KEY` | Yes | Encrypts environment variables (`openssl rand -hex 32`) |
| `INTERNAL_API_SECRET` | Yes | Encrypts internal API routes (`openssl rand -hex 32`) |
| `API_ENCRYPTION_KEY` | Yes | Encrypts API keys (`openssl rand -hex 32`) |
| `ENCRYPTION_KEY` | Yes | Encryption key (`openssl rand -hex 32`) |
| `OLLAMA_URL` | No | Ollama server URL (default: `http://localhost:11434`) |
| `VLLM_BASE_URL` | No | vLLM server URL for self-hosted models |
| `COPILOT_API_KEY` | No | API key from sim.ai for Copilot features |
## Troubleshooting

View File

@@ -1853,31 +1853,6 @@ 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
@@ -1897,19 +1872,6 @@ export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TinybirdIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
<rect x='0' y='0' width='24' height='24' fill='#2EF598' rx='6' />
<g transform='translate(2, 2) scale(0.833)'>
<path d='M25 2.64 17.195.5 14.45 6.635z' fill='#1E7F63' />
<path d='M17.535 17.77 10.39 15.215 6.195 25.5z' fill='#1E7F63' />
<path d='M0 11.495 17.535 17.77 20.41 4.36z' fill='#1F2437' />
</g>
</svg>
)
}
export function ClayIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 400 400'>
@@ -4099,31 +4061,6 @@ export function McpIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function A2AIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 860 860' fill='none' xmlns='http://www.w3.org/2000/svg'>
<circle cx='544' cy='307' r='27' fill='currentColor' />
<circle cx='154' cy='307' r='27' fill='currentColor' />
<circle cx='706' cy='307' r='27' fill='currentColor' />
<circle cx='316' cy='307' r='27' fill='currentColor' />
<path
d='M336.5 191.003H162C97.6588 191.003 45.5 243.162 45.5 307.503C45.5 371.844 97.6442 424.003 161.985 424.003C206.551 424.003 256.288 424.003 296.5 424.003C487.5 424.003 374 191.005 569 191.001C613.886 191 658.966 191 698.025 191C762.366 191.001 814.5 243.16 814.5 307.501C814.5 371.843 762.34 424.003 697.998 424.003H523.5'
stroke='currentColor'
strokeWidth='48'
strokeLinecap='round'
/>
<path
d='M256 510.002C270.359 510.002 282 521.643 282 536.002C282 550.361 270.359 562.002 256 562.002H148C133.641 562.002 122 550.361 122 536.002C122 521.643 133.641 510.002 148 510.002H256ZM712 510.002C726.359 510.002 738 521.643 738 536.002C738 550.361 726.359 562.002 712 562.002H360C345.641 562.002 334 550.361 334 536.002C334 521.643 345.641 510.002 360 510.002H712Z'
fill='currentColor'
/>
<path
d='M444 628.002C458.359 628.002 470 639.643 470 654.002C470 668.361 458.359 680.002 444 680.002H100C85.6406 680.002 74 668.361 74 654.002C74 639.643 85.6406 628.002 100 628.002H444ZM548 628.002C562.359 628.002 574 639.643 574 654.002C574 668.361 562.359 680.002 548 680.002C533.641 680.002 522 668.361 522 654.002C522 639.643 533.641 628.002 548 628.002ZM760 628.002C774.359 628.002 786 639.643 786 654.002C786 668.361 774.359 680.002 760 680.002H652C637.641 680.002 626 668.361 626 654.002C626 639.643 637.641 628.002 652 628.002H760Z'
fill='currentColor'
/>
</svg>
)
}
export function WordpressIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25.925 25.925'>

View File

@@ -4,7 +4,6 @@
import type { ComponentType, SVGProps } from 'react'
import {
A2AIcon,
AhrefsIcon,
AirtableIcon,
ApifyIcon,
@@ -55,7 +54,6 @@ import {
JiraIcon,
JiraServiceManagementIcon,
KalshiIcon,
LemlistIcon,
LinearIcon,
LinkedInIcon,
LinkupIcon,
@@ -107,7 +105,6 @@ import {
SupabaseIcon,
TavilyIcon,
TelegramIcon,
TinybirdIcon,
TranslateIcon,
TrelloIcon,
TTSIcon,
@@ -129,7 +126,6 @@ import {
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
a2a: A2AIcon,
ahrefs: AhrefsIcon,
airtable: AirtableIcon,
apify: ApifyIcon,
@@ -180,7 +176,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
jira_service_management: JiraServiceManagementIcon,
kalshi: KalshiIcon,
knowledge: PackageSearchIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,
@@ -231,8 +226,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
supabase: SupabaseIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
thinking: BrainIcon,
tinybird: TinybirdIcon,
translate: TranslateIcon,
trello: TrelloIcon,
tts: TTSIcon,

View File

@@ -6,13 +6,13 @@ description: Enterprise-Funktionen für Organisationen mit erweiterten
import { Callout } from 'fumadocs-ui/components/callout'
Sim Enterprise bietet erweiterte Funktionen für Organisationen mit erhöhten Sicherheits-, Compliance- und Verwaltungsanforderungen.
Sim Studio Enterprise bietet erweiterte Funktionen für Organisationen mit erhöhten Sicherheits-, Compliance- und Verwaltungsanforderungen.
---
## Bring Your Own Key (BYOK)
Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der gehosteten Schlüssel von Sim.
Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der gehosteten Schlüssel von Sim Studio.
### Unterstützte Anbieter
@@ -33,7 +33,7 @@ Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der ge
BYOK-Schlüssel werden verschlüsselt gespeichert. Nur Organisationsadministratoren und -inhaber können Schlüssel verwalten.
</Callout>
Wenn konfiguriert, verwenden Workflows Ihren Schlüssel anstelle der gehosteten Schlüssel von Sim. Bei Entfernung wechseln Workflows automatisch zu den gehosteten Schlüsseln zurück.
Wenn konfiguriert, verwenden Workflows Ihren Schlüssel anstelle der gehosteten Schlüssel von Sim Studio. Bei Entfernung wechseln Workflows automatisch zu den gehosteten Schlüsseln zurück.
---
@@ -73,5 +73,5 @@ Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgeb
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Workspace-/Organisations-Einladungen global deaktivieren |
<Callout type="warn">
BYOK ist nur im gehosteten Sim verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen.
BYOK ist nur im gehosteten Sim Studio verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen.
</Callout>

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: Sim mit Docker Compose bereitstellen
description: Sim Studio mit Docker Compose bereitstellen
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Umgebungsvariablen
description: Konfigurationsreferenz für Sim
description: Konfigurationsreferenz für Sim Studio
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,12 +1,12 @@
---
title: Self-Hosting
description: Stellen Sie Sim auf Ihrer eigenen Infrastruktur bereit
description: Stellen Sie Sim Studio auf Ihrer eigenen Infrastruktur bereit
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
Stellen Sie Sim auf Ihrer eigenen Infrastruktur mit Docker oder Kubernetes bereit.
Stellen Sie Sim Studio auf Ihrer eigenen Infrastruktur mit Docker oder Kubernetes bereit.
## Anforderungen

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: Sim mit Helm bereitstellen
description: Sim Studio mit Helm bereitstellen
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Cloud-Plattformen
description: Sim auf Cloud-Plattformen bereitstellen
description: Sim Studio auf Cloud-Plattformen bereitstellen
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -64,7 +64,7 @@ sudo usermod -aG docker $USER
docker --version
```
### Sim bereitstellen
### Sim Studio bereitstellen
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -5,7 +5,7 @@ description: Enterprise features for business organizations
import { Callout } from 'fumadocs-ui/components/callout'
Sim Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
Sim Studio Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
---

View File

@@ -12,7 +12,7 @@ Sim automatically calculates costs for all workflow executions, providing transp
Every workflow execution includes two cost components:
**Base Execution Charge**: $0.005 per execution
**Base Execution Charge**: $0.001 per execution
**AI Model Usage**: Variable cost based on token consumption
```javascript
@@ -48,40 +48,40 @@ The model breakdown shows:
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
<Tab>
**Hosted Models** - Sim provides API keys with a 1.1x pricing multiplier for Agent blocks:
**Hosted Models** - Sim provides API keys with a 1.4x pricing multiplier for Agent blocks:
**OpenAI**
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|-------|---------------------------|----------------------------|
| GPT-5.1 | $1.25 / $10.00 | $1.38 / $11.00 |
| GPT-5 | $1.25 / $10.00 | $1.38 / $11.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.28 / $2.20 |
| GPT-5 Nano | $0.05 / $0.40 | $0.06 / $0.44 |
| GPT-4o | $2.50 / $10.00 | $2.75 / $11.00 |
| GPT-4.1 | $2.00 / $8.00 | $2.20 / $8.80 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.44 / $1.76 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.11 / $0.44 |
| o1 | $15.00 / $60.00 | $16.50 / $66.00 |
| o3 | $2.00 / $8.00 | $2.20 / $8.80 |
| o4 Mini | $1.10 / $4.40 | $1.21 / $4.84 |
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
**Anthropic**
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|-------|---------------------------|----------------------------|
| Claude Opus 4.5 | $5.00 / $25.00 | $5.50 / $27.50 |
| Claude Opus 4.1 | $15.00 / $75.00 | $16.50 / $82.50 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $3.30 / $16.50 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $3.30 / $16.50 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.10 / $5.50 |
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
**Google**
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|-------|---------------------------|----------------------------|
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.20 / $13.20 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.38 / $11.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.33 / $2.75 |
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
*The 1.1x multiplier covers infrastructure and API management costs.*
*The 1.4x multiplier covers infrastructure and API management costs.*
</Tab>
<Tab>
@@ -106,7 +106,7 @@ The model breakdown shows:
## Bring Your Own Key (BYOK)
Use your own API keys for AI model providers instead of Sim's hosted keys to pay base prices with no markup.
Use your own API keys for AI model providers instead of Sim Studio's hosted keys to pay base prices with no markup.
### Supported Providers
@@ -127,7 +127,7 @@ Use your own API keys for AI model providers instead of Sim's hosted keys to pay
BYOK keys are encrypted at rest. Only workspace admins can manage keys.
</Callout>
When configured, workflows use your key instead of Sim's hosted keys. If removed, workflows automatically fall back to hosted keys with the multiplier.
When configured, workflows use your key instead of Sim Studio's hosted keys. If removed, workflows automatically fall back to hosted keys with the multiplier.
## Cost Optimization Strategies

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: Deploy Sim with Docker Compose
description: Deploy Sim Studio with Docker Compose
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Environment Variables
description: Configuration reference for Sim
description: Configuration reference for Sim Studio
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,18 +1,12 @@
---
title: Self-Hosting
description: Deploy Sim on your own infrastructure
description: Deploy Sim Studio on your own infrastructure
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
Deploy Sim on your own infrastructure with Docker or Kubernetes.
<div className="flex gap-2 my-4">
<a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d">
<img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor" />
</a>
</div>
Deploy Sim Studio on your own infrastructure with Docker or Kubernetes.
## Requirements
@@ -54,4 +48,3 @@ Open [http://localhost:3000](http://localhost:3000)
| realtime | 3002 | WebSocket server |
| db | 5432 | PostgreSQL with pgvector |
| migrations | - | Database migrations (runs once) |

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: Deploy Sim with Helm
description: Deploy Sim Studio with Helm
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Cloud Platforms
description: Deploy Sim on cloud platforms
description: Deploy Sim Studio on cloud platforms
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -57,7 +57,7 @@ sudo usermod -aG docker $USER
docker --version
```
### Deploy Sim
### Deploy Sim Studio
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -1,212 +0,0 @@
---
title: A2A
description: Interact with external A2A-compatible agents
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="a2a"
color="#4151B5"
/>
{/* MANUAL-CONTENT-START:intro */}
The A2A (Agent-to-Agent) protocol enables Sim to interact with external AI agents and systems that implement A2A-compatible APIs. With A2A, you can connect Sims automations and workflows to remote agents—such as LLM-powered bots, microservices, and other AI-based tools—using a standardized messaging format.
Using the A2A tools in Sim, you can:
- **Send Messages to External Agents**: Communicate directly with remote agents, providing prompts, commands, or data.
- **Receive and Stream Responses**: Get structured responses, artifacts, or real-time updates from the agent as the task progresses.
- **Continue Conversations or Tasks**: Carry on multi-turn conversations or workflows by referencing task and context IDs.
- **Integrate Third-Party AI and Automation**: Leverage external A2A-compatible services as part of your Sim workflows.
These features allow you to build advanced workflows that combine Sims native capabilities with the intelligence and automation of external AIs or custom agents. To use A2A integrations, youll need the external agents endpoint URL and, if required, an API key or credentials.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Use the A2A (Agent-to-Agent) protocol to interact with external AI agents.
## Tools
### `a2a_send_message`
Send a message to an external A2A-compatible agent.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `message` | string | Yes | Message to send to the agent |
| `taskId` | string | No | Task ID for continuing an existing task |
| `contextId` | string | No | Context ID for conversation continuity |
| `data` | string | No | Structured data to include with the message \(JSON string\) |
| `files` | array | No | Files to include with the message |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | The text response from the agent |
| `taskId` | string | Task ID for follow-up interactions |
| `contextId` | string | Context ID for conversation continuity |
| `state` | string | Task state |
| `artifacts` | array | Structured output artifacts |
| `history` | array | Full message history |
### `a2a_get_task`
Query the status of an existing A2A task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to query |
| `apiKey` | string | No | API key for authentication |
| `historyLength` | number | No | Number of history messages to include |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskId` | string | Task ID |
| `contextId` | string | Context ID |
| `state` | string | Task state |
| `artifacts` | array | Output artifacts |
| `history` | array | Message history |
### `a2a_cancel_task`
Cancel a running A2A task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to cancel |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `cancelled` | boolean | Whether cancellation was successful |
| `state` | string | Task state after cancellation |
### `a2a_get_agent_card`
Fetch the Agent Card (discovery document) for an A2A agent.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `apiKey` | string | No | API key for authentication \(if required\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `name` | string | Agent name |
| `description` | string | Agent description |
| `url` | string | Agent endpoint URL |
| `version` | string | Agent version |
| `capabilities` | object | Agent capabilities \(streaming, pushNotifications, etc.\) |
| `skills` | array | Skills the agent can perform |
| `defaultInputModes` | array | Default input modes \(text, file, data\) |
| `defaultOutputModes` | array | Default output modes \(text, file, data\) |
### `a2a_resubscribe`
Reconnect to an ongoing A2A task stream after connection interruption.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to resubscribe to |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskId` | string | Task ID |
| `contextId` | string | Context ID |
| `state` | string | Current task state |
| `isRunning` | boolean | Whether the task is still running |
| `artifacts` | array | Output artifacts |
| `history` | array | Message history |
### `a2a_set_push_notification`
Configure a webhook to receive task update notifications.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to configure notifications for |
| `webhookUrl` | string | Yes | HTTPS webhook URL to receive notifications |
| `token` | string | No | Token for webhook validation |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `url` | string | Configured webhook URL |
| `token` | string | Token for webhook validation |
| `success` | boolean | Whether configuration was successful |
### `a2a_get_push_notification`
Get the push notification webhook configuration for a task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to get notification config for |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `url` | string | Configured webhook URL |
| `token` | string | Token for webhook validation |
| `exists` | boolean | Whether a push notification config exists |
### `a2a_delete_push_notification`
Delete the push notification webhook configuration for a task.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
| `taskId` | string | Yes | Task ID to delete notification config for |
| `pushNotificationConfigId` | string | No | Push notification configuration ID to delete \(optional - server can derive from taskId\) |
| `apiKey` | string | No | API key for authentication |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether deletion was successful |

View File

@@ -1,94 +0,0 @@
---
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 |
| `leadIdentifier` | string | Yes | Lead email address or lead 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

@@ -1,7 +1,6 @@
{
"pages": [
"index",
"a2a",
"ahrefs",
"airtable",
"apify",
@@ -52,7 +51,6 @@
"jira_service_management",
"kalshi",
"knowledge",
"lemlist",
"linear",
"linkedin",
"linkup",
@@ -103,8 +101,6 @@
"supabase",
"tavily",
"telegram",
"thinking",
"tinybird",
"translate",
"trello",
"tts",

View File

@@ -124,45 +124,6 @@ Read the latest messages from Slack channels. Retrieve conversation history with
| --------- | ---- | ----------- |
| `messages` | array | Array of message objects from the channel |
### `slack_get_message`
Retrieve a specific message by its timestamp. Useful for getting a thread parent message.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | Yes | Slack channel ID \(e.g., C1234567890\) |
| `timestamp` | string | Yes | Message timestamp to retrieve \(e.g., 1405894322.002768\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | object | The retrieved message object |
### `slack_get_thread`
Retrieve an entire thread including the parent message and all replies. Useful for getting full conversation context.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | Yes | Slack channel ID \(e.g., C1234567890\) |
| `threadTs` | string | Yes | Thread timestamp \(thread_ts\) to retrieve \(e.g., 1405894322.002768\) |
| `limit` | number | No | Maximum number of messages to return \(default: 100, max: 200\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `parentMessage` | object | The thread parent message |
### `slack_list_channels`
List all channels in a Slack workspace. Returns public and private channels the bot has access to.

View File

@@ -1,70 +0,0 @@
---
title: Tinybird
description: Send events and query data with Tinybird
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="tinybird"
color="#2EF598"
/>
## Usage Instructions
Interact with Tinybird using the Events API to stream JSON or NDJSON events, or use the Query API to execute SQL queries against Pipes and Data Sources.
## Tools
### `tinybird_events`
Send events to a Tinybird Data Source using the Events API. Supports JSON and NDJSON formats with optional gzip compression.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co or https://api.us-east.tinybird.co\) |
| `datasource` | string | Yes | Name of the Tinybird Data Source to send events to |
| `data` | string | Yes | Data to send as NDJSON \(newline-delimited JSON\) or JSON string. Each event should be a valid JSON object. |
| `wait` | boolean | No | Wait for database acknowledgment before responding. Enables safer retries but introduces latency. Defaults to false. |
| `format` | string | No | Format of the events data: "ndjson" \(default\) or "json" |
| `compression` | string | No | Compression format: "none" \(default\) or "gzip" |
| `token` | string | Yes | Tinybird API Token with DATASOURCE:APPEND or DATASOURCE:CREATE scope |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `successful_rows` | number | Number of rows successfully ingested |
| `quarantined_rows` | number | Number of rows quarantined \(failed validation\) |
### `tinybird_query`
Execute SQL queries against Tinybird Pipes and Data Sources using the Query API.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co\) |
| `query` | string | Yes | SQL query to execute. Specify your desired output format \(e.g., FORMAT JSON, FORMAT CSV, FORMAT TSV\). JSON format provides structured data, while other formats return raw text. |
| `pipeline` | string | No | Optional pipe name. When provided, enables SELECT * FROM _ syntax |
| `token` | string | Yes | Tinybird API Token with PIPE:READ scope |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | json | Query result data. For FORMAT JSON: array of objects. For other formats \(CSV, TSV, etc.\): raw text string. |
| `rows` | number | Number of rows returned \(only available with FORMAT JSON\) |
| `statistics` | json | Query execution statistics - elapsed time, rows read, bytes read \(only available with FORMAT JSON\) |
## Notes
- Category: `tools`
- Type: `tinybird`

View File

@@ -6,13 +6,13 @@ description: Funciones enterprise para organizaciones con requisitos avanzados
import { Callout } from 'fumadocs-ui/components/callout'
Sim Enterprise proporciona funciones avanzadas para organizaciones con requisitos mejorados de seguridad, cumplimiento y gestión.
Sim Studio Enterprise proporciona funciones avanzadas para organizaciones con requisitos mejorados de seguridad, cumplimiento y gestión.
---
## Bring Your Own Key (BYOK)
Usa tus propias claves API para proveedores de modelos de IA en lugar de las claves alojadas de Sim.
Usa tus propias claves API para proveedores de modelos de IA en lugar de las claves alojadas de Sim Studio.
### Proveedores compatibles
@@ -33,7 +33,7 @@ Usa tus propias claves API para proveedores de modelos de IA en lugar de las cla
Las claves BYOK están cifradas en reposo. Solo los administradores y propietarios de la organización pueden gestionar las claves.
</Callout>
Cuando está configurado, los flujos de trabajo usan tu clave en lugar de las claves alojadas de Sim. Si se elimina, los flujos de trabajo vuelven automáticamente a las claves alojadas.
Cuando está configurado, los flujos de trabajo usan tu clave en lugar de las claves alojadas de Sim Studio. Si se elimina, los flujos de trabajo vuelven automáticamente a las claves alojadas.
---
@@ -73,5 +73,5 @@ Para implementaciones self-hosted, las funciones enterprise se pueden activar me
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Desactivar globalmente invitaciones a espacios de trabajo/organizaciones |
<Callout type="warn">
BYOK solo está disponible en Sim alojado. Las implementaciones autoalojadas configuran las claves de proveedor de IA directamente a través de variables de entorno.
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.
</Callout>

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: Despliega Sim con Docker Compose
description: Despliega Sim Studio con Docker Compose
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Variables de entorno
description: Referencia de configuración para Sim
description: Referencia de configuración para Sim Studio
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,12 +1,12 @@
---
title: Autoalojamiento
description: Despliega Sim en tu propia infraestructura
description: Despliega Sim Studio en tu propia infraestructura
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
Despliega Sim en tu propia infraestructura con Docker o Kubernetes.
Despliega Sim Studio en tu propia infraestructura con Docker o Kubernetes.
## Requisitos

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: Desplegar Sim con Helm
description: Desplegar Sim Studio con Helm
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Plataformas en la nube
description: Despliega Sim en plataformas en la nube
description: Despliega Sim Studio en plataformas en la nube
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -64,7 +64,7 @@ sudo usermod -aG docker $USER
docker --version
```
### Desplegar Sim
### Desplegar Sim Studio
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -6,13 +6,13 @@ description: Fonctionnalités entreprise pour les organisations ayant des
import { Callout } from 'fumadocs-ui/components/callout'
Sim Entreprise fournit des fonctionnalités avancées pour les organisations ayant des exigences renforcées en matière de sécurité, de conformité et de gestion.
Sim Studio Entreprise fournit des fonctionnalités avancées pour les organisations ayant des exigences renforcées en matière de sécurité, de conformité et de gestion.
---
## Apportez votre propre clé (BYOK)
Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des clés hébergées par Sim.
Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des clés hébergées par Sim Studio.
### Fournisseurs pris en charge
@@ -33,7 +33,7 @@ Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des
Les clés BYOK sont chiffrées au repos. Seuls les administrateurs et propriétaires de l'organisation peuvent gérer les clés.
</Callout>
Une fois configurés, les workflows utilisent votre clé au lieu des clés hébergées par Sim. Si elle est supprimée, les workflows basculent automatiquement vers les clés hébergées.
Une fois configurés, les workflows utilisent votre clé au lieu des clés hébergées par Sim Studio. Si elle est supprimée, les workflows basculent automatiquement vers les clés hébergées.
---
@@ -73,5 +73,5 @@ Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent
| `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 hébergé. Les déploiements auto-hébergés configurent les clés de fournisseur d'IA directement via les variables d'environnement.
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.
</Callout>

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: Déployer Sim avec Docker Compose
description: Déployer Sim Studio avec Docker Compose
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Variables d'environnement
description: Référence de configuration pour Sim
description: Référence de configuration pour Sim Studio
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,12 +1,12 @@
---
title: Auto-hébergement
description: Déployez Sim sur votre propre infrastructure
description: Déployez Sim Studio sur votre propre infrastructure
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
Déployez Sim sur votre propre infrastructure avec Docker ou Kubernetes.
Déployez Sim Studio sur votre propre infrastructure avec Docker ou Kubernetes.
## Prérequis

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: Déployer Sim avec Helm
description: Déployer Sim Studio avec Helm
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: Plateformes cloud
description: Déployer Sim sur des plateformes cloud
description: Déployer Sim Studio sur des plateformes cloud
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -64,7 +64,7 @@ sudo usermod -aG docker $USER
docker --version
```
### Déployer Sim
### Déployer Sim Studio
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -5,13 +5,13 @@ description: 高度なセキュリティとコンプライアンス要件を持
import { Callout } from 'fumadocs-ui/components/callout'
Sim Enterpriseは、強化されたセキュリティ、コンプライアンス、管理要件を持つ組織向けの高度な機能を提供します。
Sim Studio Enterpriseは、強化されたセキュリティ、コンプライアンス、管理要件を持つ組織向けの高度な機能を提供します。
---
## Bring Your Own Key (BYOK)
Simのホストキーの代わりに、AIモデルプロバイダー用の独自のAPIキーを使用できます。
Sim Studioのホストキーの代わりに、AIモデルプロバイダー用の独自のAPIキーを使用できます。
### 対応プロバイダー
@@ -32,7 +32,7 @@ Simのホストキーの代わりに、AIモデルプロバイダー用の独自
BYOKキーは保存時に暗号化されます。組織の管理者とオーナーのみがキーを管理できます。
</Callout>
設定すると、ワークフローはSimのホストキーの代わりに独自のキーを使用します。削除すると、ワークフローは自動的にホストキーにフォールバックします。
設定すると、ワークフローはSim Studioのホストキーの代わりに独自のキーを使用します。削除すると、ワークフローは自動的にホストキーにフォールバックします。
---
@@ -72,5 +72,5 @@ Simのホストキーの代わりに、AIモデルプロバイダー用の独自
| `DISABLE_INVITATIONS`、`NEXT_PUBLIC_DISABLE_INVITATIONS` | ワークスペース/組織への招待をグローバルに無効化 |
<Callout type="warn">
BYOKはホスト型Simでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。
BYOKはホスト型Sim Studioでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。
</Callout>

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: Docker Composeを使用してSimをデプロイする
description: Docker Composeを使用してSim Studioをデプロイする
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: 環境変数
description: Simの設定リファレンス
description: Sim Studioの設定リファレンス
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,12 +1,12 @@
---
title: セルフホスティング
description: 自社のインフラストラクチャにSimをデプロイ
description: 自社のインフラストラクチャにSim Studioをデプロイ
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
DockerまたはKubernetesを使用して、自社のインフラストラクチャにSimをデプロイします。
DockerまたはKubernetesを使用して、自社のインフラストラクチャにSim Studioをデプロイします。
## 要件

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: Helmを使用してSimをデプロイする
description: Helmを使用してSim Studioをデプロイする
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: クラウドプラットフォーム
description: クラウドプラットフォームにSimをデプロイする
description: クラウドプラットフォームにSim Studioをデプロイする
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -64,7 +64,7 @@ sudo usermod -aG docker $USER
docker --version
```
### Simのデプロイ
### Sim Studioのデプロイ
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -5,13 +5,13 @@ description: 为具有高级安全性和合规性需求的组织提供企业级
import { Callout } from 'fumadocs-ui/components/callout'
Sim 企业版为需要更高安全性、合规性和管理能力的组织提供高级功能。
Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织提供高级功能。
---
## 自带密钥BYOK
使用您自己的 API 密钥对接 AI 模型服务商,而不是使用 Sim 托管的密钥。
使用您自己的 API 密钥对接 AI 模型服务商,而不是使用 Sim Studio 托管的密钥。
### 支持的服务商
@@ -32,7 +32,7 @@ Sim 企业版为需要更高安全性、合规性和管理能力的组织提供
BYOK 密钥静态加密存储。仅组织管理员和所有者可管理密钥。
</Callout>
配置后,工作流将使用您的密钥而非 Sim 托管密钥。如移除,工作流会自动切换回托管密钥。
配置后,工作流将使用您的密钥而非 Sim Studio 托管密钥。如移除,工作流会自动切换回托管密钥。
---
@@ -72,5 +72,5 @@ Sim 企业版为需要更高安全性、合规性和管理能力的组织提供
| `DISABLE_INVITATIONS``NEXT_PUBLIC_DISABLE_INVITATIONS` | 全局禁用工作区/组织邀请 |
<Callout type="warn">
BYOK 仅适用于托管版 Sim。自托管部署需通过环境变量直接配置 AI 提供商密钥。
BYOK 仅适用于托管版 Sim Studio。自托管部署需通过环境变量直接配置 AI 提供商密钥。
</Callout>

View File

@@ -1,6 +1,6 @@
---
title: Docker
description: 使用 Docker Compose 部署 Sim
description: 使用 Docker Compose 部署 Sim Studio
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: 环境变量
description: Sim 的配置参考
description: Sim Studio 的配置参考
---
import { Callout } from 'fumadocs-ui/components/callout'

View File

@@ -1,12 +1,12 @@
---
title: 自托管
description: 在您自己的基础设施上部署 Sim
description: 在您自己的基础设施上部署 Sim Studio
---
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Callout } from 'fumadocs-ui/components/callout'
使用 Docker 或 Kubernetes 在您自己的基础设施上部署 Sim。
使用 Docker 或 Kubernetes 在您自己的基础设施上部署 Sim Studio
## 要求

View File

@@ -1,6 +1,6 @@
---
title: Kubernetes
description: 使用 Helm 部署 Sim
description: 使用 Helm 部署 Sim Studio
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

View File

@@ -1,6 +1,6 @@
---
title: 云平台
description: 在云平台上部署 Sim
description: 在云平台上部署 Sim Studio
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -64,7 +64,7 @@ sudo usermod -aG docker $USER
docker --version
```
### 部署 Sim
### 部署 Sim Studio
```bash
git clone https://github.com/simstudioai/sim.git && cd sim

View File

@@ -6,7 +6,7 @@ export default function StructuredData() {
'@type': 'Organization',
'@id': 'https://sim.ai/#organization',
name: 'Sim',
alternateName: 'Sim',
alternateName: 'Sim Studio',
description:
'Open-source AI agent workflow builder used by developers at trail-blazing startups to Fortune 500 companies',
url: 'https://sim.ai',

View File

@@ -0,0 +1,11 @@
'use client'
import { Tooltip } from '@/components/emcn'
interface TooltipProviderProps {
children: React.ReactNode
}
export function TooltipProvider({ children }: TooltipProviderProps) {
return <Tooltip.Provider>{children}</Tooltip.Provider>
}

View File

@@ -58,6 +58,25 @@
pointer-events: none !important;
}
/**
* Workflow canvas cursor styles
* Override React Flow's default selection cursor based on canvas mode
*/
.workflow-container.canvas-mode-cursor .react-flow__pane,
.workflow-container.canvas-mode-cursor .react-flow__selectionpane {
cursor: default !important;
}
.workflow-container.canvas-mode-hand .react-flow__pane,
.workflow-container.canvas-mode-hand .react-flow__selectionpane {
cursor: grab !important;
}
.workflow-container.canvas-mode-hand .react-flow__pane:active,
.workflow-container.canvas-mode-hand .react-flow__selectionpane:active {
cursor: grabbing !important;
}
/**
* Selected node ring indicator
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
@@ -657,6 +676,20 @@ input[type="search"]::-ms-clear {
}
}
/**
* Notification toast enter animation
*/
@keyframes notification-enter {
from {
opacity: 0;
transform: translateX(-16px);
}
to {
opacity: 1;
transform: translateX(var(--stack-offset, 0px));
}
}
/**
* @depricated
* Legacy globals (light/dark) kept for backward-compat with old classes.

View File

@@ -1,289 +0,0 @@
import { db } from '@sim/db'
import { a2aAgent, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getRedisClient } from '@/lib/core/config/redis'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
const logger = createLogger('A2AAgentCardAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
/**
* GET - Returns the Agent Card for discovery
*/
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const [agent] = await db
.select({
agent: a2aAgent,
workflow: workflow,
})
.from(a2aAgent)
.innerJoin(workflow, eq(a2aAgent.workflowId, workflow.id))
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!agent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
if (!agent.agent.isPublished) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
}
}
const agentCard = generateAgentCard(
{
id: agent.agent.id,
name: agent.agent.name,
description: agent.agent.description,
version: agent.agent.version,
capabilities: agent.agent.capabilities as AgentCapabilities,
skills: agent.agent.skills as AgentSkill[],
},
{
id: agent.workflow.id,
name: agent.workflow.name,
description: agent.workflow.description,
}
)
return NextResponse.json(agentCard, {
headers: {
'Content-Type': 'application/json',
'Cache-Control': agent.agent.isPublished ? 'public, max-age=3600' : 'private, no-cache',
},
})
} catch (error) {
logger.error('Error getting Agent Card:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PUT - Update an agent
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [existingAgent] = await db
.select()
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!existingAgent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const body = await request.json()
if (
body.skillTags !== undefined &&
(!Array.isArray(body.skillTags) ||
!body.skillTags.every((tag: unknown): tag is string => typeof tag === 'string'))
) {
return NextResponse.json({ error: 'skillTags must be an array of strings' }, { status: 400 })
}
let skills = body.skills ?? existingAgent.skills
if (body.skillTags !== undefined) {
const agentName = body.name ?? existingAgent.name
const agentDescription = body.description ?? existingAgent.description
skills = generateSkillsFromWorkflow(agentName, agentDescription, body.skillTags)
}
const [updatedAgent] = await db
.update(a2aAgent)
.set({
name: body.name ?? existingAgent.name,
description: body.description ?? existingAgent.description,
version: body.version ?? existingAgent.version,
capabilities: body.capabilities ?? existingAgent.capabilities,
skills,
authentication: body.authentication ?? existingAgent.authentication,
isPublished: body.isPublished ?? existingAgent.isPublished,
publishedAt:
body.isPublished && !existingAgent.isPublished ? new Date() : existingAgent.publishedAt,
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
.returning()
logger.info(`Updated A2A agent: ${agentId}`)
return NextResponse.json({ success: true, agent: updatedAgent })
} catch (error) {
logger.error('Error updating agent:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE - Delete an agent
*/
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [existingAgent] = await db
.select()
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!existingAgent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
logger.info(`Deleted A2A agent: ${agentId}`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting agent:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST - Publish/unpublish an agent
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { agentId } = await params
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn('A2A agent publish auth failed:', { error: auth.error, hasUserId: !!auth.userId })
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const [existingAgent] = await db
.select()
.from(a2aAgent)
.where(eq(a2aAgent.id, agentId))
.limit(1)
if (!existingAgent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const body = await request.json()
const action = body.action as 'publish' | 'unpublish' | 'refresh'
if (action === 'publish') {
const [wf] = await db
.select({ isDeployed: workflow.isDeployed })
.from(workflow)
.where(eq(workflow.id, existingAgent.workflowId))
.limit(1)
if (!wf?.isDeployed) {
return NextResponse.json(
{ error: 'Workflow must be deployed before publishing agent' },
{ status: 400 }
)
}
await db
.update(a2aAgent)
.set({
isPublished: true,
publishedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
const redis = getRedisClient()
if (redis) {
try {
await redis.del(`a2a:agent:${agentId}:card`)
} catch (err) {
logger.warn('Failed to invalidate agent card cache', { agentId, error: err })
}
}
logger.info(`Published A2A agent: ${agentId}`)
return NextResponse.json({ success: true, isPublished: true })
}
if (action === 'unpublish') {
await db
.update(a2aAgent)
.set({
isPublished: false,
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
const redis = getRedisClient()
if (redis) {
try {
await redis.del(`a2a:agent:${agentId}:card`)
} catch (err) {
logger.warn('Failed to invalidate agent card cache', { agentId, error: err })
}
}
logger.info(`Unpublished A2A agent: ${agentId}`)
return NextResponse.json({ success: true, isPublished: false })
}
if (action === 'refresh') {
const workflowData = await loadWorkflowFromNormalizedTables(existingAgent.workflowId)
if (!workflowData) {
return NextResponse.json({ error: 'Failed to load workflow' }, { status: 500 })
}
const [wf] = await db
.select({ name: workflow.name, description: workflow.description })
.from(workflow)
.where(eq(workflow.id, existingAgent.workflowId))
.limit(1)
const skills = generateSkillsFromWorkflow(wf?.name || existingAgent.name, wf?.description)
await db
.update(a2aAgent)
.set({
skills,
updatedAt: new Date(),
})
.where(eq(a2aAgent.id, agentId))
logger.info(`Refreshed skills for A2A agent: ${agentId}`)
return NextResponse.json({ success: true, skills })
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
} catch (error) {
logger.error('Error with agent action:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,186 +0,0 @@
/**
* A2A Agents List Endpoint
*
* List and create A2A agents for a workspace.
*/
import { db } from '@sim/db'
import { a2aAgent, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants'
import { sanitizeAgentName } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('A2AAgentsAPI')
export const dynamic = 'force-dynamic'
/**
* GET - List all A2A agents for a workspace
*/
export async function GET(request: NextRequest) {
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
const agents = await db
.select({
id: a2aAgent.id,
workspaceId: a2aAgent.workspaceId,
workflowId: a2aAgent.workflowId,
name: a2aAgent.name,
description: a2aAgent.description,
version: a2aAgent.version,
capabilities: a2aAgent.capabilities,
skills: a2aAgent.skills,
authentication: a2aAgent.authentication,
isPublished: a2aAgent.isPublished,
publishedAt: a2aAgent.publishedAt,
createdAt: a2aAgent.createdAt,
updatedAt: a2aAgent.updatedAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
isDeployed: workflow.isDeployed,
taskCount: sql<number>`(
SELECT COUNT(*)::int
FROM "a2a_task"
WHERE "a2a_task"."agent_id" = "a2a_agent"."id"
)`.as('task_count'),
})
.from(a2aAgent)
.leftJoin(workflow, eq(a2aAgent.workflowId, workflow.id))
.where(eq(a2aAgent.workspaceId, workspaceId))
.orderBy(a2aAgent.createdAt)
logger.info(`Listed ${agents.length} A2A agents for workspace ${workspaceId}`)
return NextResponse.json({ success: true, agents })
} catch (error) {
logger.error('Error listing agents:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST - Create a new A2A agent from a workflow
*/
export async function POST(request: NextRequest) {
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { workspaceId, workflowId, name, description, capabilities, authentication, skillTags } =
body
if (!workspaceId || !workflowId) {
return NextResponse.json(
{ error: 'workspaceId and workflowId are required' },
{ status: 400 }
)
}
const [wf] = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
workspaceId: workflow.workspaceId,
isDeployed: workflow.isDeployed,
})
.from(workflow)
.where(and(eq(workflow.id, workflowId), eq(workflow.workspaceId, workspaceId)))
.limit(1)
if (!wf) {
return NextResponse.json(
{ error: 'Workflow not found or does not belong to workspace' },
{ status: 404 }
)
}
const workflowData = await loadWorkflowFromNormalizedTables(workflowId)
if (!workflowData || !hasValidStartBlockInState(workflowData)) {
return NextResponse.json(
{ error: 'Workflow must have a Start block to be exposed as an A2A agent' },
{ status: 400 }
)
}
const [existing] = await db
.select({ id: a2aAgent.id })
.from(a2aAgent)
.where(and(eq(a2aAgent.workspaceId, workspaceId), eq(a2aAgent.workflowId, workflowId)))
.limit(1)
if (existing) {
return NextResponse.json(
{ error: 'An agent already exists for this workflow' },
{ status: 409 }
)
}
const skills = generateSkillsFromWorkflow(
name || wf.name,
description || wf.description,
skillTags
)
const agentId = uuidv4()
const agentName = name || sanitizeAgentName(wf.name)
const [agent] = await db
.insert(a2aAgent)
.values({
id: agentId,
workspaceId,
workflowId,
createdBy: auth.userId,
name: agentName,
description: description || wf.description,
version: '1.0.0',
capabilities: {
...A2A_DEFAULT_CAPABILITIES,
...capabilities,
},
skills,
authentication: authentication || {
schemes: ['bearer', 'apiKey'],
},
isPublished: false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
logger.info(`Created A2A agent ${agentId} for workflow ${workflowId}`)
return NextResponse.json({ success: true, agent }, { status: 201 })
} catch (error) {
logger.error('Error creating agent:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,176 +0,0 @@
import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk'
import { v4 as uuidv4 } from 'uuid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl } from '@/lib/core/utils/urls'
/** A2A v0.3 JSON-RPC method names */
export const A2A_METHODS = {
MESSAGE_SEND: 'message/send',
MESSAGE_STREAM: 'message/stream',
TASKS_GET: 'tasks/get',
TASKS_CANCEL: 'tasks/cancel',
TASKS_RESUBSCRIBE: 'tasks/resubscribe',
PUSH_NOTIFICATION_SET: 'tasks/pushNotificationConfig/set',
PUSH_NOTIFICATION_GET: 'tasks/pushNotificationConfig/get',
PUSH_NOTIFICATION_DELETE: 'tasks/pushNotificationConfig/delete',
} as const
/** A2A v0.3 error codes */
export const A2A_ERROR_CODES = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
TASK_NOT_FOUND: -32001,
TASK_ALREADY_COMPLETE: -32002,
AGENT_UNAVAILABLE: -32003,
AUTHENTICATION_REQUIRED: -32004,
} as const
export interface JSONRPCRequest {
jsonrpc: '2.0'
id: string | number
method: string
params?: unknown
}
export interface JSONRPCResponse {
jsonrpc: '2.0'
id: string | number | null
result?: unknown
error?: {
code: number
message: string
data?: unknown
}
}
export interface MessageSendParams {
message: Message
configuration?: {
acceptedOutputModes?: string[]
historyLength?: number
pushNotificationConfig?: PushNotificationConfig
}
}
export interface TaskIdParams {
id: string
historyLength?: number
}
export interface PushNotificationSetParams {
id: string
pushNotificationConfig: PushNotificationConfig
}
export function createResponse(id: string | number | null, result: unknown): JSONRPCResponse {
return { jsonrpc: '2.0', id, result }
}
export function createError(
id: string | number | null,
code: number,
message: string,
data?: unknown
): JSONRPCResponse {
return { jsonrpc: '2.0', id, error: { code, message, data } }
}
export function isJSONRPCRequest(obj: unknown): obj is JSONRPCRequest {
if (!obj || typeof obj !== 'object') return false
const r = obj as Record<string, unknown>
return r.jsonrpc === '2.0' && typeof r.method === 'string' && r.id !== undefined
}
export function generateTaskId(): string {
return uuidv4()
}
export function createTaskStatus(state: TaskState): { state: TaskState; timestamp: string } {
return { state, timestamp: new Date().toISOString() }
}
export function formatTaskResponse(task: Task, historyLength?: number): Task {
if (historyLength !== undefined && task.history) {
return {
...task,
history: task.history.slice(-historyLength),
}
}
return task
}
export interface ExecuteRequestConfig {
workflowId: string
apiKey?: string | null
stream?: boolean
}
export interface ExecuteRequestResult {
url: string
headers: Record<string, string>
useInternalAuth: boolean
}
export async function buildExecuteRequest(
config: ExecuteRequestConfig
): Promise<ExecuteRequestResult> {
const url = `${getBaseUrl()}/api/workflows/${config.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
let useInternalAuth = false
if (config.apiKey) {
headers['X-API-Key'] = config.apiKey
} else {
const internalToken = await generateInternalToken()
headers.Authorization = `Bearer ${internalToken}`
useInternalAuth = true
}
if (config.stream) {
headers['X-Stream-Response'] = 'true'
}
return { url, headers, useInternalAuth }
}
export function extractAgentContent(executeResult: {
output?: { content?: string; [key: string]: unknown }
error?: string
}): string {
// Prefer explicit content field
if (executeResult.output?.content) {
return executeResult.output.content
}
// If output is an object with meaningful data, stringify it
if (typeof executeResult.output === 'object' && executeResult.output !== null) {
const keys = Object.keys(executeResult.output)
// Skip empty objects or objects with only undefined values
if (keys.length > 0 && keys.some((k) => executeResult.output![k] !== undefined)) {
return JSON.stringify(executeResult.output)
}
}
// Fallback to error message or default
return executeResult.error || 'Task completed'
}
export function buildTaskResponse(params: {
taskId: string
contextId: string
state: TaskState
history: Message[]
artifacts?: Artifact[]
}): Task {
return {
kind: 'task',
id: params.taskId,
contextId: params.contextId,
status: createTaskStatus(params.state),
history: params.history,
artifacts: params.artifacts || [],
}
}

View File

@@ -97,7 +97,6 @@ const ChatMessageSchema = z.object({
})
)
.optional(),
commands: z.array(z.string()).optional(),
})
/**
@@ -133,7 +132,6 @@ export async function POST(req: NextRequest) {
provider,
conversationId,
contexts,
commands,
} = ChatMessageSchema.parse(body)
// Ensure we have a consistent user message ID for this request
const userMessageIdToUse = userMessageId || crypto.randomUUID()
@@ -464,7 +462,6 @@ export async function POST(req: NextRequest) {
...(integrationTools.length > 0 && { tools: integrationTools }),
...(baseTools.length > 0 && { baseTools }),
...(credentials && { credentials }),
...(commands && commands.length > 0 && { commands }),
}
try {

View File

@@ -1,12 +1,11 @@
import { db } from '@sim/db'
import { memory } from '@sim/db/schema'
import { memory, permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MemoryByIdAPI')
@@ -30,6 +29,46 @@ const memoryPutBodySchema = z.object({
workspaceId: z.string().uuid('Invalid workspace ID format'),
})
async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<{ hasAccess: boolean; canWrite: boolean }> {
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRow) {
return { hasAccess: false, canWrite: false }
}
if (workspaceRow.ownerId === userId) {
return { hasAccess: true, canWrite: true }
}
const [permissionRow] = await db
.select({ permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!permissionRow) {
return { hasAccess: false, canWrite: false }
}
return {
hasAccess: true,
canWrite: permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin',
}
}
async function validateMemoryAccess(
request: NextRequest,
workspaceId: string,
@@ -47,8 +86,8 @@ async function validateMemoryAccess(
}
}
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists || !access.hasAccess) {
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
return {
error: NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
@@ -57,7 +96,7 @@ async function validateMemoryAccess(
}
}
if (action === 'write' && !access.canWrite) {
if (action === 'write' && !canWrite) {
return {
error: NextResponse.json(
{ success: false, error: { message: 'Write access denied' } },

View File

@@ -1,17 +1,56 @@
import { db } from '@sim/db'
import { memory } from '@sim/db/schema'
import { memory, permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, like } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MemoryAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<{ hasAccess: boolean; canWrite: boolean }> {
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRow) {
return { hasAccess: false, canWrite: false }
}
if (workspaceRow.ownerId === userId) {
return { hasAccess: true, canWrite: true }
}
const [permissionRow] = await db
.select({ permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!permissionRow) {
return { hasAccess: false, canWrite: false }
}
return {
hasAccess: true,
canWrite: permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin',
}
}
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
@@ -37,14 +76,8 @@ export async function GET(request: NextRequest) {
)
}
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists) {
return NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
{ status: 404 }
)
}
if (!access.hasAccess) {
const { hasAccess } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Access denied to this workspace' } },
{ status: 403 }
@@ -122,21 +155,15 @@ export async function POST(request: NextRequest) {
)
}
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists) {
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
{ status: 404 }
)
}
if (!access.hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Access denied to this workspace' } },
{ status: 403 }
)
}
if (!access.canWrite) {
if (!canWrite) {
return NextResponse.json(
{ success: false, error: { message: 'Write access denied to this workspace' } },
{ status: 403 }
@@ -255,21 +282,15 @@ export async function DELETE(request: NextRequest) {
)
}
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!access.exists) {
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
if (!hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Workspace not found' } },
{ status: 404 }
)
}
if (!access.hasAccess) {
return NextResponse.json(
{ success: false, error: { message: 'Access denied to this workspace' } },
{ status: 403 }
)
}
if (!access.canWrite) {
if (!canWrite) {
return NextResponse.json(
{ success: false, error: { message: 'Write access denied to this workspace' } },
{ status: 403 }

View File

@@ -1,84 +0,0 @@
import type { Task } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('A2ACancelTaskAPI')
export const dynamic = 'force-dynamic'
const A2ACancelTaskSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A cancel task attempt`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const body = await request.json()
const validatedData = A2ACancelTaskSchema.parse(body)
logger.info(`[${requestId}] Canceling A2A task`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const task = (await client.cancelTask({ id: validatedData.taskId })) as Task
logger.info(`[${requestId}] Successfully canceled A2A task`, {
taskId: validatedData.taskId,
state: task.status.state,
})
return NextResponse.json({
success: true,
output: {
cancelled: true,
state: task.status.state,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid A2A cancel task request`, {
errors: error.errors,
})
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error canceling A2A task:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to cancel task',
},
{ status: 500 }
)
}
}

View File

@@ -1,94 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ADeletePushNotificationAPI')
const A2ADeletePushNotificationSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
pushNotificationConfigId: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized A2A delete push notification attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A delete push notification request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2ADeletePushNotificationSchema.parse(body)
logger.info(`[${requestId}] Deleting A2A push notification config`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
pushNotificationConfigId: validatedData.pushNotificationConfigId,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
await client.deleteTaskPushNotificationConfig({
id: validatedData.taskId,
pushNotificationConfigId: validatedData.pushNotificationConfigId || validatedData.taskId,
})
logger.info(`[${requestId}] Push notification config deleted successfully`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
success: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error deleting A2A push notification:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to delete push notification',
},
{ status: 500 }
)
}
}

View File

@@ -1,92 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2AGetAgentCardAPI')
const A2AGetAgentCardSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A get agent card attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A get agent card request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2AGetAgentCardSchema.parse(body)
logger.info(`[${requestId}] Fetching Agent Card`, {
agentUrl: validatedData.agentUrl,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const agentCard = await client.getAgentCard()
logger.info(`[${requestId}] Agent Card fetched successfully`, {
agentName: agentCard.name,
})
return NextResponse.json({
success: true,
output: {
name: agentCard.name,
description: agentCard.description,
url: agentCard.url,
version: agentCard.protocolVersion,
capabilities: agentCard.capabilities,
skills: agentCard.skills,
defaultInputModes: agentCard.defaultInputModes,
defaultOutputModes: agentCard.defaultOutputModes,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error fetching Agent Card:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch Agent Card',
},
{ status: 500 }
)
}
}

View File

@@ -1,115 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2AGetPushNotificationAPI')
const A2AGetPushNotificationSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized A2A get push notification attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A get push notification request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2AGetPushNotificationSchema.parse(body)
logger.info(`[${requestId}] Getting push notification config`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const result = await client.getTaskPushNotificationConfig({
id: validatedData.taskId,
})
if (!result || !result.pushNotificationConfig) {
logger.info(`[${requestId}] No push notification config found for task`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
exists: false,
},
})
}
logger.info(`[${requestId}] Push notification config retrieved successfully`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
url: result.pushNotificationConfig.url,
token: result.pushNotificationConfig.token,
exists: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
if (error instanceof Error && error.message.includes('not found')) {
logger.info(`[${requestId}] Task not found, returning exists: false`)
return NextResponse.json({
success: true,
output: {
exists: false,
},
})
}
logger.error(`[${requestId}] Error getting A2A push notification:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get push notification',
},
{ status: 500 }
)
}
}

View File

@@ -1,95 +0,0 @@
import type { Task } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2AGetTaskAPI')
const A2AGetTaskSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
historyLength: z.number().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A get task attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated A2A get task request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const validatedData = A2AGetTaskSchema.parse(body)
logger.info(`[${requestId}] Getting A2A task`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
historyLength: validatedData.historyLength,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const task = (await client.getTask({
id: validatedData.taskId,
historyLength: validatedData.historyLength,
})) as Task
logger.info(`[${requestId}] Successfully retrieved A2A task`, {
taskId: task.id,
state: task.status.state,
})
return NextResponse.json({
success: true,
output: {
taskId: task.id,
contextId: task.contextId,
state: task.status.state,
artifacts: task.artifacts,
history: task.history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error getting A2A task:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get task',
},
{ status: 500 }
)
}
}

View File

@@ -1,119 +0,0 @@
import type {
Artifact,
Message,
Task,
TaskArtifactUpdateEvent,
TaskState,
TaskStatusUpdateEvent,
} from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('A2AResubscribeAPI')
export const dynamic = 'force-dynamic'
const A2AResubscribeSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A resubscribe attempt`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const body = await request.json()
const validatedData = A2AResubscribeSchema.parse(body)
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const stream = client.resubscribeTask({ id: validatedData.taskId })
let taskId = validatedData.taskId
let contextId: string | undefined
let state: TaskState = 'working'
let content = ''
let artifacts: Artifact[] = []
let history: Message[] = []
for await (const event of stream) {
if (event.kind === 'message') {
const msg = event as Message
content = extractTextContent(msg)
taskId = msg.taskId || taskId
contextId = msg.contextId || contextId
state = 'completed'
} else if (event.kind === 'task') {
const task = event as Task
taskId = task.id
contextId = task.contextId
state = task.status.state
artifacts = task.artifacts || []
history = task.history || []
const lastAgentMessage = history.filter((m) => m.role === 'agent').pop()
if (lastAgentMessage) {
content = extractTextContent(lastAgentMessage)
}
} else if ('status' in event) {
const statusEvent = event as TaskStatusUpdateEvent
state = statusEvent.status.state
} else if ('artifact' in event) {
const artifactEvent = event as TaskArtifactUpdateEvent
artifacts.push(artifactEvent.artifact)
}
}
logger.info(`[${requestId}] Successfully resubscribed to A2A task ${taskId}`)
return NextResponse.json({
success: true,
output: {
taskId,
contextId,
state,
isRunning: !isTerminalState(state),
artifacts,
history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid A2A resubscribe data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error resubscribing to A2A task:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to resubscribe',
},
{ status: 500 }
)
}
}

View File

@@ -1,217 +0,0 @@
import type { DataPart, FilePart, Message, Part, Task, TextPart } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASendMessageAPI')
const FileInputSchema = z.object({
type: z.enum(['file', 'url']),
data: z.string(),
name: z.string(),
mime: z.string().optional(),
})
const A2ASendMessageSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
message: z.string().min(1, 'Message is required'),
taskId: z.string().optional(),
contextId: z.string().optional(),
data: z.string().optional(),
files: z.array(FileInputSchema).optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A send message attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A send message request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2ASendMessageSchema.parse(body)
logger.info(`[${requestId}] Sending A2A message`, {
agentUrl: validatedData.agentUrl,
hasTaskId: !!validatedData.taskId,
hasContextId: !!validatedData.contextId,
})
let client
try {
client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
logger.info(`[${requestId}] A2A client created successfully`)
} catch (clientError) {
logger.error(`[${requestId}] Failed to create A2A client:`, clientError)
return NextResponse.json(
{
success: false,
error: `Failed to connect to agent: ${clientError instanceof Error ? clientError.message : 'Unknown error'}`,
},
{ status: 502 }
)
}
const parts: Part[] = []
const textPart: TextPart = { kind: 'text', text: validatedData.message }
parts.push(textPart)
if (validatedData.data) {
try {
const parsedData = JSON.parse(validatedData.data)
const dataPart: DataPart = { kind: 'data', data: parsedData }
parts.push(dataPart)
} catch (parseError) {
logger.warn(`[${requestId}] Failed to parse data as JSON, skipping DataPart`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
})
}
}
if (validatedData.files && validatedData.files.length > 0) {
for (const file of validatedData.files) {
if (file.type === 'url') {
const filePart: FilePart = {
kind: 'file',
file: {
name: file.name,
mimeType: file.mime,
uri: file.data,
},
}
parts.push(filePart)
} else if (file.type === 'file') {
let bytes = file.data
let mimeType = file.mime
if (file.data.startsWith('data:')) {
const match = file.data.match(/^data:([^;]+);base64,(.+)$/)
if (match) {
mimeType = mimeType || match[1]
bytes = match[2]
} else {
bytes = file.data
}
}
const filePart: FilePart = {
kind: 'file',
file: {
name: file.name,
mimeType: mimeType || 'application/octet-stream',
bytes,
},
}
parts.push(filePart)
}
}
}
const message: Message = {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'user',
parts,
...(validatedData.taskId && { taskId: validatedData.taskId }),
...(validatedData.contextId && { contextId: validatedData.contextId }),
}
let result
try {
result = await client.sendMessage({ message })
logger.info(`[${requestId}] A2A sendMessage completed`, { resultKind: result?.kind })
} catch (sendError) {
logger.error(`[${requestId}] Failed to send A2A message:`, sendError)
return NextResponse.json(
{
success: false,
error: `Failed to send message: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
},
{ status: 502 }
)
}
if (result.kind === 'message') {
const responseMessage = result as Message
logger.info(`[${requestId}] A2A message sent successfully (message response)`)
return NextResponse.json({
success: true,
output: {
content: extractTextContent(responseMessage),
taskId: responseMessage.taskId || '',
contextId: responseMessage.contextId,
state: 'completed',
},
})
}
const task = result as Task
const lastAgentMessage = task.history?.filter((m) => m.role === 'agent').pop()
const content = lastAgentMessage ? extractTextContent(lastAgentMessage) : ''
logger.info(`[${requestId}] A2A message sent successfully (task response)`, {
taskId: task.id,
state: task.status.state,
})
return NextResponse.json({
success: isTerminalState(task.status.state) && task.status.state !== 'failed',
output: {
content,
taskId: task.id,
contextId: task.contextId,
state: task.status.state,
artifacts: task.artifacts,
history: task.history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error sending A2A message:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -1,93 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASetPushNotificationAPI')
const A2ASetPushNotificationSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
taskId: z.string().min(1, 'Task ID is required'),
webhookUrl: z.string().min(1, 'Webhook URL is required'),
token: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized A2A set push notification attempt`, {
error: authResult.error || 'Authentication required',
})
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const body = await request.json()
const validatedData = A2ASetPushNotificationSchema.parse(body)
logger.info(`[${requestId}] A2A set push notification request`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,
webhookUrl: validatedData.webhookUrl,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const result = await client.setTaskPushNotificationConfig({
taskId: validatedData.taskId,
pushNotificationConfig: {
url: validatedData.webhookUrl,
token: validatedData.token,
},
})
logger.info(`[${requestId}] A2A set push notification successful`, {
taskId: validatedData.taskId,
})
return NextResponse.json({
success: true,
output: {
url: result.pushNotificationConfig.url,
token: result.pushNotificationConfig.token,
success: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error setting A2A push notification:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to set push notification',
},
{ status: 500 }
)
}
}

View File

@@ -27,10 +27,11 @@ const SettingsSchema = z.object({
superUserModeEnabled: z.boolean().optional(),
errorNotificationsEnabled: z.boolean().optional(),
snapToGridSize: z.number().min(0).max(50).optional(),
showActionBar: z.boolean().optional(),
})
const defaultSettings = {
theme: 'system',
theme: 'dark',
autoConnect: true,
telemetryEnabled: true,
emailPreferences: {},
@@ -39,6 +40,7 @@ const defaultSettings = {
superUserModeEnabled: false,
errorNotificationsEnabled: true,
snapToGridSize: 0,
showActionBar: true,
}
export async function GET() {
@@ -73,6 +75,7 @@ export async function GET() {
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
showActionBar: userSettings.showActionBar ?? true,
},
},
{ status: 200 }

View File

@@ -1,247 +0,0 @@
/**
* GET /api/v1/admin/folders/[id]/export
*
* Export a folder and all its contents (workflows + subfolders) as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
*
* Response:
* - ZIP file download (Content-Type: application/zip)
* - JSON: FolderExportFullPayload
*/
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type FolderExportPayload,
parseWorkflowVariables,
type WorkflowExportState,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminFolderExportAPI')
interface RouteParams {
id: string
}
interface CollectedWorkflow {
id: string
folderId: string | null
}
/**
* Recursively collects all workflows within a folder and its subfolders.
*/
function collectWorkflowsInFolder(
folderId: string,
allWorkflows: Array<{ id: string; folderId: string | null }>,
allFolders: Array<{ id: string; parentId: string | null }>
): CollectedWorkflow[] {
const collected: CollectedWorkflow[] = []
for (const wf of allWorkflows) {
if (wf.folderId === folderId) {
collected.push({ id: wf.id, folderId: wf.folderId })
}
}
for (const folder of allFolders) {
if (folder.parentId === folderId) {
const childWorkflows = collectWorkflowsInFolder(folder.id, allWorkflows, allFolders)
collected.push(...childWorkflows)
}
}
return collected
}
/**
* Collects all subfolders recursively under a root folder.
* Returns folders with parentId adjusted so direct children of rootFolderId have parentId: null.
*/
function collectSubfolders(
rootFolderId: string,
allFolders: Array<{ id: string; name: string; parentId: string | null }>
): FolderExportPayload[] {
const subfolders: FolderExportPayload[] = []
function collect(parentId: string) {
for (const folder of allFolders) {
if (folder.parentId === parentId) {
subfolders.push({
id: folder.id,
name: folder.name,
parentId: folder.parentId === rootFolderId ? null : folder.parentId,
})
collect(folder.id)
}
}
}
collect(rootFolderId)
return subfolders
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: folderId } = await context.params
const url = new URL(request.url)
const format = url.searchParams.get('format') || 'zip'
try {
const [folderData] = await db
.select({
id: workflowFolder.id,
name: workflowFolder.name,
workspaceId: workflowFolder.workspaceId,
})
.from(workflowFolder)
.where(eq(workflowFolder.id, folderId))
.limit(1)
if (!folderData) {
return notFoundResponse('Folder')
}
const allWorkflows = await db
.select({ id: workflow.id, folderId: workflow.folderId })
.from(workflow)
.where(eq(workflow.workspaceId, folderData.workspaceId))
const allFolders = await db
.select({
id: workflowFolder.id,
name: workflowFolder.name,
parentId: workflowFolder.parentId,
})
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, folderData.workspaceId))
const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders)
const subfolders = collectSubfolders(folderId, allFolders)
const workflowExports: Array<{
workflow: {
id: string
name: string
description: string | null
color: string | null
folderId: string | null
}
state: WorkflowExportState
}> = []
for (const collectedWf of workflowsInFolder) {
try {
const [wfData] = await db
.select()
.from(workflow)
.where(eq(workflow.id, collectedWf.id))
.limit(1)
if (!wfData) {
logger.warn(`Skipping workflow ${collectedWf.id} - not found`)
continue
}
const normalizedData = await loadWorkflowFromNormalizedTables(collectedWf.id)
if (!normalizedData) {
logger.warn(`Skipping workflow ${collectedWf.id} - no normalized data found`)
continue
}
const variables = parseWorkflowVariables(wfData.variables)
const remappedFolderId = collectedWf.folderId === folderId ? null : collectedWf.folderId
const state: WorkflowExportState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: wfData.name,
description: wfData.description ?? undefined,
color: wfData.color,
exportedAt: new Date().toISOString(),
},
variables,
}
workflowExports.push({
workflow: {
id: wfData.id,
name: wfData.name,
description: wfData.description,
color: wfData.color,
folderId: remappedFolderId,
},
state,
})
} catch (error) {
logger.error(`Failed to load workflow ${collectedWf.id}:`, { error })
}
}
logger.info(
`Admin API: Exporting folder ${folderId} with ${workflowExports.length} workflows and ${subfolders.length} subfolders`
)
if (format === 'json') {
const exportPayload = {
version: '1.0',
exportedAt: new Date().toISOString(),
folder: {
id: folderData.id,
name: folderData.name,
},
workflows: workflowExports,
folders: subfolders,
}
return singleResponse(exportPayload)
}
const zipWorkflows = workflowExports.map((wf) => ({
workflow: {
id: wf.workflow.id,
name: wf.workflow.name,
description: wf.workflow.description ?? undefined,
color: wf.workflow.color ?? undefined,
folderId: wf.workflow.folderId,
},
state: wf.state,
variables: wf.state.variables,
}))
const zipBlob = await exportFolderToZip(folderData.name, zipWorkflows, subfolders)
const arrayBuffer = await zipBlob.arrayBuffer()
const sanitizedName = sanitizePathSegment(folderData.name)
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': arrayBuffer.byteLength.toString(),
},
})
} catch (error) {
logger.error('Admin API: Failed to export folder', { error, folderId })
return internalErrorResponse('Failed to export folder')
}
})

View File

@@ -34,16 +34,12 @@
* GET /api/v1/admin/workflows/:id - Get workflow details
* DELETE /api/v1/admin/workflows/:id - Delete workflow
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
* POST /api/v1/admin/workflows/export - Export multiple workflows (ZIP/JSON)
* POST /api/v1/admin/workflows/import - Import single workflow
* POST /api/v1/admin/workflows/:id/deploy - Deploy workflow
* DELETE /api/v1/admin/workflows/:id/deploy - Undeploy workflow
* GET /api/v1/admin/workflows/:id/versions - List deployment versions
* POST /api/v1/admin/workflows/:id/versions/:vid/activate - Activate specific version
*
* Folders:
* GET /api/v1/admin/folders/:id/export - Export folder with contents (ZIP/JSON)
*
* Organizations:
* GET /api/v1/admin/organizations - List all organizations
* POST /api/v1/admin/organizations - Create organization (requires ownerId)

View File

@@ -1,7 +1,7 @@
/**
* GET /api/v1/admin/workflows/[id]/export
*
* Export a single workflow as JSON (raw, unsanitized for admin backup/restore).
* Export a single workflow as JSON.
*
* Response: AdminSingleResponse<WorkflowExportPayload>
*/

View File

@@ -1,147 +0,0 @@
/**
* POST /api/v1/admin/workflows/export
*
* Export multiple workflows as a ZIP file or JSON array (raw, unsanitized for admin backup/restore).
*
* Request Body:
* - ids: string[] - Array of workflow IDs to export
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
*
* Response:
* - ZIP file download (Content-Type: application/zip) - each workflow as JSON in root
* - JSON: AdminListResponse<WorkflowExportPayload[]>
*/
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { inArray } from 'drizzle-orm'
import JSZip from 'jszip'
import { NextResponse } from 'next/server'
import { sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
import {
parseWorkflowVariables,
type WorkflowExportPayload,
type WorkflowExportState,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowsExportAPI')
interface ExportRequest {
ids: string[]
}
export const POST = withAdminAuth(async (request) => {
const url = new URL(request.url)
const format = url.searchParams.get('format') || 'zip'
let body: ExportRequest
try {
body = await request.json()
} catch {
return badRequestResponse('Invalid JSON body')
}
if (!body.ids || !Array.isArray(body.ids) || body.ids.length === 0) {
return badRequestResponse('ids must be a non-empty array of workflow IDs')
}
try {
const workflows = await db.select().from(workflow).where(inArray(workflow.id, body.ids))
if (workflows.length === 0) {
return badRequestResponse('No workflows found with the provided IDs')
}
const workflowExports: WorkflowExportPayload[] = []
for (const wf of workflows) {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(wf.id)
if (!normalizedData) {
logger.warn(`Skipping workflow ${wf.id} - no normalized data found`)
continue
}
const variables = parseWorkflowVariables(wf.variables)
const state: WorkflowExportState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: wf.name,
description: wf.description ?? undefined,
color: wf.color,
exportedAt: new Date().toISOString(),
},
variables,
}
const exportPayload: WorkflowExportPayload = {
version: '1.0',
exportedAt: new Date().toISOString(),
workflow: {
id: wf.id,
name: wf.name,
description: wf.description,
color: wf.color,
workspaceId: wf.workspaceId,
folderId: wf.folderId,
},
state,
}
workflowExports.push(exportPayload)
} catch (error) {
logger.error(`Failed to load workflow ${wf.id}:`, { error })
}
}
logger.info(`Admin API: Exporting ${workflowExports.length} workflows`)
if (format === 'json') {
return listResponse(workflowExports, {
total: workflowExports.length,
limit: workflowExports.length,
offset: 0,
hasMore: false,
})
}
const zip = new JSZip()
for (const exportPayload of workflowExports) {
const filename = `${sanitizePathSegment(exportPayload.workflow.name)}.json`
zip.file(filename, JSON.stringify(exportPayload, null, 2))
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const arrayBuffer = await zipBlob.arrayBuffer()
const filename = `workflows-export-${new Date().toISOString().split('T')[0]}.zip`
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': arrayBuffer.byteLength.toString(),
},
})
} catch (error) {
logger.error('Admin API: Failed to export workflows', { error, ids: body.ids })
return internalErrorResponse('Failed to export workflows')
}
})

View File

@@ -1,7 +1,7 @@
/**
* GET /api/v1/admin/workspaces/[id]/export
*
* Export an entire workspace as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
* Export an entire workspace as a ZIP file or JSON.
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
@@ -16,7 +16,7 @@ import { workflow, workflowFolder, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { exportWorkspaceToZip } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -146,7 +146,7 @@ export const GET = withAdminAuthParams<RouteParams>(async (request, context) =>
const zipBlob = await exportWorkspaceToZip(workspaceData.name, zipWorkflows, folderExports)
const arrayBuffer = await zipBlob.arrayBuffer()
const sanitizedName = sanitizePathSegment(workspaceData.name)
const sanitizedName = workspaceData.name.replace(/[^a-z0-9-_]/gi, '-')
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
return new NextResponse(arrayBuffer, {

View File

@@ -0,0 +1,79 @@
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,58 +793,6 @@ 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({
@@ -1368,116 +1316,3 @@ 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

@@ -0,0 +1,114 @@
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

@@ -0,0 +1,522 @@
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,6 +152,7 @@ export async function POST(
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
testMode: false,
executionTarget: 'deployed',
})
responses.push(response)

View File

@@ -27,7 +27,7 @@ import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
import type { StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
const logger = createLogger('WorkflowExecuteAPI')
@@ -109,7 +109,7 @@ type AsyncExecutionParams = {
workflowId: string
userId: string
input: any
triggerType: CoreTriggerType
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
}
/**
@@ -215,10 +215,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowStateOverride,
} = validation.data
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
// For API key auth, the entire body is the input (except for our control fields)
// For session auth, the input is explicitly provided in the input field
const input =
auth.authType === 'api_key' || auth.authType === 'internal_jwt'
auth.authType === 'api_key'
? (() => {
const {
selectedOutputs,
@@ -226,7 +226,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
stream,
useDraftState,
workflowStateOverride,
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
...rest
} = body
return Object.keys(rest).length > 0 ? rest : validatedInput
@@ -253,9 +252,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
const executionId = uuidv4()
let loggingTriggerType: CoreTriggerType = 'manual'
if (CORE_TRIGGER_TYPES.includes(triggerType as CoreTriggerType)) {
loggingTriggerType = triggerType as CoreTriggerType
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
let loggingTriggerType: LoggingTriggerType = 'manual'
if (
triggerType === 'api' ||
triggerType === 'chat' ||
triggerType === 'webhook' ||
triggerType === 'schedule' ||
triggerType === 'manual' ||
triggerType === 'mcp'
) {
loggingTriggerType = triggerType as LoggingTriggerType
}
const loggingSession = new LoggingSession(
workflowId,

View File

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowAPI')
@@ -36,9 +36,13 @@ export async function GET(request: Request) {
const userId = session.user.id
if (workspaceId) {
const wsExists = await workspaceExists(workspaceId)
const workspaceExists = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.then((rows) => rows.length > 0)
if (!wsExists) {
if (!workspaceExists) {
logger.warn(
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { apiKey } from '@sim/db/schema'
import { apiKey, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { nanoid } from 'nanoid'
@@ -9,7 +9,7 @@ import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceApiKeysAPI')
@@ -34,8 +34,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
if (!ws.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { workspaceBYOKKeys } from '@sim/db/schema'
import { workspace, workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
@@ -8,7 +8,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceBYOKKeysAPI')
@@ -46,8 +46,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
if (!ws.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { environment, workspaceEnvironment } from '@sim/db/schema'
import { environment, workspace, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -7,7 +7,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceEnvironmentAPI')
@@ -33,8 +33,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
// Validate workspace exists
const ws = await getWorkspaceById(workspaceId)
if (!ws) {
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
if (!ws.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

View File

@@ -2,6 +2,13 @@ import { createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Tests for workspace invitation by ID API route
* Tests GET (details + token acceptance), DELETE (cancellation)
*
* @vitest-environment node
*/
const mockGetSession = vi.fn()
const mockHasWorkspaceAdminAccess = vi.fn()
@@ -220,7 +227,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w')
})
it('should redirect to error page with token preserved when invitation expired', async () => {
it('should redirect to error page when invitation expired', async () => {
const session = createSession({
userId: mockUser.id,
email: 'invited@example.com',
@@ -243,13 +250,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/invitation-789?error=expired&token=token-abc123'
expect(response.headers.get('location')).toBe(
'https://test.sim.ai/invite/invitation-789?error=expired'
)
})
it('should redirect to error page with token preserved when email mismatch', async () => {
it('should redirect to error page when email mismatch', async () => {
const session = createSession({
userId: mockUser.id,
email: 'wrong@example.com',
@@ -271,13 +277,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123'
expect(response.headers.get('location')).toBe(
'https://test.sim.ai/invite/invitation-789?error=email-mismatch'
)
})
it('should return 404 when invitation not found (without token)', async () => {
it('should return 404 when invitation not found', async () => {
const session = createSession({ userId: mockUser.id, email: mockUser.email })
mockGetSession.mockResolvedValue(session)
dbSelectResults = [[]]
@@ -291,189 +296,6 @@ describe('Workspace Invitation [invitationId] API Route', () => {
expect(response.status).toBe(404)
expect(data).toEqual({ error: 'Invitation not found or has expired' })
})
it('should redirect to error page with token preserved when invitation not found (with token)', async () => {
const session = createSession({ userId: mockUser.id, email: mockUser.email })
mockGetSession.mockResolvedValue(session)
dbSelectResults = [[]]
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/non-existent?token=some-invalid-token'
)
const params = Promise.resolve({ invitationId: 'non-existent' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/non-existent?error=invalid-token&token=some-invalid-token'
)
})
it('should redirect to error page with token preserved when invitation already processed', async () => {
const session = createSession({
userId: mockUser.id,
email: 'invited@example.com',
name: mockUser.name,
})
mockGetSession.mockResolvedValue(session)
const acceptedInvitation = {
...mockInvitation,
status: 'accepted',
}
dbSelectResults = [[acceptedInvitation], [mockWorkspace]]
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/invitation-789?error=already-processed&token=token-abc123'
)
})
it('should redirect to error page with token preserved when workspace not found', async () => {
const session = createSession({
userId: mockUser.id,
email: 'invited@example.com',
name: mockUser.name,
})
mockGetSession.mockResolvedValue(session)
dbSelectResults = [[mockInvitation], []]
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/invitation-789?error=workspace-not-found&token=token-abc123'
)
})
it('should redirect to error page with token preserved when user not found', async () => {
const session = createSession({
userId: mockUser.id,
email: 'invited@example.com',
name: mockUser.name,
})
mockGetSession.mockResolvedValue(session)
dbSelectResults = [[mockInvitation], [mockWorkspace], []]
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/invitation-789?error=user-not-found&token=token-abc123'
)
})
it('should URL encode special characters in token when preserving in error redirects', async () => {
const session = createSession({
userId: mockUser.id,
email: 'wrong@example.com',
name: mockUser.name,
})
mockGetSession.mockResolvedValue(session)
dbSelectResults = [
[mockInvitation],
[mockWorkspace],
[{ ...mockUser, email: 'wrong@example.com' }],
]
const specialToken = 'token+with/special=chars&more'
const request = new NextRequest(
`http://localhost/api/workspaces/invitations/token-abc123?token=${encodeURIComponent(specialToken)}`
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toContain('error=email-mismatch')
expect(location).toContain(`token=${encodeURIComponent(specialToken)}`)
})
})
describe('Token Preservation - Full Flow Scenario', () => {
it('should preserve token through email mismatch so user can retry with correct account', async () => {
const wrongSession = createSession({
userId: 'wrong-user',
email: 'wrong@example.com',
name: 'Wrong User',
})
mockGetSession.mockResolvedValue(wrongSession)
dbSelectResults = [
[mockInvitation],
[mockWorkspace],
[{ id: 'wrong-user', email: 'wrong@example.com' }],
]
const request1 = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params1 = Promise.resolve({ invitationId: 'token-abc123' })
const response1 = await GET(request1, { params: params1 })
expect(response1.status).toBe(307)
const location1 = response1.headers.get('location')
expect(location1).toBe(
'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123'
)
vi.clearAllMocks()
dbSelectCallIndex = 0
const correctSession = createSession({
userId: mockUser.id,
email: 'invited@example.com',
name: mockUser.name,
})
mockGetSession.mockResolvedValue(correctSession)
dbSelectResults = [
[mockInvitation],
[mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }],
[],
]
const request2 = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params2 = Promise.resolve({ invitationId: 'token-abc123' })
const response2 = await GET(request2, { params: params2 })
expect(response2.status).toBe(307)
expect(response2.headers.get('location')).toBe(
'https://test.sim.ai/workspace/workspace-456/w'
)
})
})
describe('DELETE /api/workspaces/invitations/[invitationId]', () => {

View File

@@ -31,6 +31,7 @@ export async function GET(
const isAcceptFlow = !!token // If token is provided, this is an acceptance flow
if (!session?.user?.id) {
// For token-based acceptance flows, redirect to login
if (isAcceptFlow) {
return NextResponse.redirect(new URL(`/invite/${invitationId}?token=${token}`, getBaseUrl()))
}
@@ -50,9 +51,8 @@ export async function GET(
if (!invitation) {
if (isAcceptFlow) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
return NextResponse.redirect(
new URL(`/invite/${invitationId}?error=invalid-token${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitationId}?error=invalid-token`, getBaseUrl())
)
}
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
@@ -60,9 +60,8 @@ export async function GET(
if (new Date() > new Date(invitation.expiresAt)) {
if (isAcceptFlow) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
return NextResponse.redirect(
new URL(`/invite/${invitation.id}?error=expired${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitation.id}?error=expired`, getBaseUrl())
)
}
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
@@ -76,20 +75,17 @@ export async function GET(
if (!workspaceDetails) {
if (isAcceptFlow) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
return NextResponse.redirect(
new URL(`/invite/${invitation.id}?error=workspace-not-found${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitation.id}?error=workspace-not-found`, getBaseUrl())
)
}
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (isAcceptFlow) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
return NextResponse.redirect(
new URL(`/invite/${invitation.id}?error=already-processed${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitation.id}?error=already-processed`, getBaseUrl())
)
}
@@ -104,7 +100,7 @@ export async function GET(
if (!userData) {
return NextResponse.redirect(
new URL(`/invite/${invitation.id}?error=user-not-found${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitation.id}?error=user-not-found`, getBaseUrl())
)
}
@@ -112,7 +108,7 @@ export async function GET(
if (!isValidMatch) {
return NextResponse.redirect(
new URL(`/invite/${invitation.id}?error=email-mismatch${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitation.id}?error=email-mismatch`, getBaseUrl())
)
}

View File

@@ -178,25 +178,23 @@ export default function Invite() {
useEffect(() => {
const errorReason = searchParams.get('error')
const isNew = searchParams.get('new') === 'true'
setIsNewUser(isNew)
const tokenFromQuery = searchParams.get('token')
if (tokenFromQuery) {
setToken(tokenFromQuery)
sessionStorage.setItem('inviteToken', tokenFromQuery)
} else {
const storedToken = sessionStorage.getItem('inviteToken')
if (storedToken && storedToken !== inviteId) {
setToken(storedToken)
}
}
if (errorReason) {
setError(getInviteError(errorReason))
setIsLoading(false)
return
}
const isNew = searchParams.get('new') === 'true'
setIsNewUser(isNew)
const tokenFromQuery = searchParams.get('token')
const effectiveToken = tokenFromQuery || inviteId
if (effectiveToken) {
setToken(effectiveToken)
sessionStorage.setItem('inviteToken', effectiveToken)
}
}, [searchParams, inviteId])
useEffect(() => {
@@ -205,6 +203,7 @@ export default function Invite() {
async function fetchInvitationDetails() {
setIsLoading(true)
try {
// Fetch invitation details using the invitation ID from the URL path
const workspaceInviteResponse = await fetch(`/api/workspaces/invitations/${inviteId}`, {
method: 'GET',
})
@@ -221,6 +220,7 @@ export default function Invite() {
return
}
// Handle workspace invitation errors with specific status codes
if (!workspaceInviteResponse.ok && workspaceInviteResponse.status !== 404) {
const errorCode = parseApiError(null, workspaceInviteResponse.status)
const errorData = await workspaceInviteResponse.json().catch(() => ({}))
@@ -229,6 +229,7 @@ export default function Invite() {
error: errorData,
})
// Refine error code based on response body if available
if (errorData.error) {
const refinedCode = parseApiError(errorData.error, workspaceInviteResponse.status)
setError(getInviteError(refinedCode))
@@ -253,11 +254,13 @@ export default function Invite() {
if (data) {
setInvitationType('organization')
// Check if user is already in an organization BEFORE showing the invitation
const activeOrgResponse = await client.organization
.getFullOrganization()
.catch(() => ({ data: null }))
if (activeOrgResponse?.data) {
// User is already in an organization
setCurrentOrgName(activeOrgResponse.data.name)
setError(getInviteError('already-in-organization'))
setIsLoading(false)
@@ -286,6 +289,7 @@ export default function Invite() {
throw { code: 'invalid-invitation' }
}
} catch (orgErr: any) {
// If this is our structured error, use it directly
if (orgErr.code) {
throw orgErr
}
@@ -312,6 +316,7 @@ export default function Invite() {
window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}`
} else {
try {
// Get the organizationId from invitation details
const orgId = invitationDetails?.data?.organizationId
if (!orgId) {
@@ -320,6 +325,7 @@ export default function Invite() {
return
}
// Use our custom API endpoint that handles Pro usage snapshot
const response = await fetch(`/api/organizations/${orgId}/invitations/${inviteId}`, {
method: 'PUT',
headers: {
@@ -341,6 +347,7 @@ export default function Invite() {
return
}
// Set the organization as active
await client.organization.setActive({
organizationId: orgId,
})
@@ -353,6 +360,7 @@ export default function Invite() {
} catch (err: any) {
logger.error('Error accepting invitation:', err)
// Reset accepted state on error
setAccepted(false)
const errorCode = parseApiError(err)
@@ -363,9 +371,7 @@ export default function Invite() {
}
const getCallbackUrl = () => {
const effectiveToken =
token || sessionStorage.getItem('inviteToken') || searchParams.get('token')
return `/invite/${inviteId}${effectiveToken && effectiveToken !== inviteId ? `?token=${effectiveToken}` : ''}`
return `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}`
}
if (!session?.user && !isPending) {
@@ -429,6 +435,7 @@ export default function Invite() {
if (error) {
const callbackUrl = encodeURIComponent(getCallbackUrl())
// Special handling for already in organization
if (error.code === 'already-in-organization') {
return (
<InviteLayout>
@@ -456,6 +463,7 @@ export default function Invite() {
)
}
// Handle email mismatch - user needs to sign in with a different account
if (error.code === 'email-mismatch') {
return (
<InviteLayout>
@@ -482,6 +490,7 @@ export default function Invite() {
)
}
// Handle auth-related errors - prompt user to sign in
if (error.requiresAuth) {
return (
<InviteLayout>
@@ -509,6 +518,7 @@ export default function Invite() {
)
}
// Handle retryable errors
const actions: Array<{
label: string
onClick: () => void
@@ -540,6 +550,7 @@ export default function Invite() {
)
}
// Show success only if accepted AND no error
if (accepted && !error) {
return (
<InviteLayout>

View File

@@ -11,6 +11,7 @@ import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
import { QueryProvider } from '@/app/_shell/providers/query-provider'
import { SessionProvider } from '@/app/_shell/providers/session-provider'
import { ThemeProvider } from '@/app/_shell/providers/theme-provider'
import { TooltipProvider } from '@/app/_shell/providers/tooltip-provider'
import { season } from '@/app/_styles/fonts/season/season'
export const viewport: Viewport = {
@@ -194,7 +195,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<ThemeProvider>
<QueryProvider>
<SessionProvider>
<BrandedLayout>{children}</BrandedLayout>
<TooltipProvider>
<BrandedLayout>{children}</BrandedLayout>
</TooltipProvider>
</SessionProvider>
</QueryProvider>
</ThemeProvider>

View File

@@ -11,9 +11,9 @@ export const metadata: Metadata = {
'Open-source AI agent workflow builder used by 60,000+ developers. Build and deploy agentic workflows with a visual drag-and-drop canvas. Connect 100+ apps and ship SOC2 & HIPAA-ready AI automations from startups to Fortune 500.',
keywords:
'AI agent workflow builder, agentic workflows, open source AI, visual workflow builder, AI automation, LLM workflows, AI agents, workflow automation, no-code AI, SOC2 compliant, HIPAA compliant, enterprise AI',
authors: [{ name: 'Sim' }],
creator: 'Sim',
publisher: 'Sim',
authors: [{ name: 'Sim Studio' }],
creator: 'Sim Studio',
publisher: 'Sim Studio',
formatDetection: {
email: false,
address: false,

View File

@@ -21,12 +21,15 @@ import {
Combobox,
Connections,
Copy,
Cursor,
DatePicker,
DocumentAttachment,
Duplicate,
Expand,
Eye,
FolderCode,
FolderPlus,
Hand,
HexSimple,
Input,
Key as KeyIcon,
@@ -364,30 +367,12 @@ export default function PlaygroundPage() {
</VariantRow>
<VariantRow label='tag variants'>
<Tag value='valid@email.com' variant='default' />
<Tag value='secondary-tag' variant='secondary' />
<Tag value='invalid-email' variant='invalid' />
</VariantRow>
<VariantRow label='tag with remove'>
<Tag value='removable@tag.com' variant='default' onRemove={() => {}} />
<Tag value='secondary-removable' variant='secondary' onRemove={() => {}} />
<Tag value='invalid-removable' variant='invalid' onRemove={() => {}} />
</VariantRow>
<VariantRow label='secondary variant'>
<div className='w-80'>
<TagInput
items={[
{ value: 'workflow', isValid: true },
{ value: 'automation', isValid: true },
]}
onAdd={() => true}
onRemove={() => {}}
placeholder='Add tags'
placeholderWithTags='Add another'
tagVariant='secondary'
triggerKeys={['Enter', ',']}
/>
</div>
</VariantRow>
<VariantRow label='disabled'>
<div className='w-80'>
<TagInput
@@ -991,11 +976,14 @@ export default function PlaygroundPage() {
{ Icon: ChevronDown, name: 'ChevronDown' },
{ Icon: Connections, name: 'Connections' },
{ Icon: Copy, name: 'Copy' },
{ Icon: Cursor, name: 'Cursor' },
{ Icon: DocumentAttachment, name: 'DocumentAttachment' },
{ Icon: Duplicate, name: 'Duplicate' },
{ Icon: Expand, name: 'Expand' },
{ Icon: Eye, name: 'Eye' },
{ Icon: FolderCode, name: 'FolderCode' },
{ Icon: FolderPlus, name: 'FolderPlus' },
{ Icon: Hand, name: 'Hand' },
{ Icon: HexSimple, name: 'HexSimple' },
{ Icon: KeyIcon, name: 'Key' },
{ Icon: Layout, name: 'Layout' },

View File

@@ -1,15 +1,12 @@
'use client'
import { Tooltip } from '@/components/emcn'
import { season } from '@/app/_styles/fonts/season/season'
export default function TemplatesLayoutClient({ children }: { children: React.ReactNode }) {
return (
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className={`${season.variable} relative flex min-h-screen flex-col font-season`}>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
{children}
</div>
</Tooltip.Provider>
<div className={`${season.variable} relative flex min-h-screen flex-col font-season`}>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
{children}
</div>
)
}

View File

@@ -1,6 +1,5 @@
'use client'
import { Tooltip } from '@/components/emcn'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -13,16 +12,14 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className='flex h-screen w-full bg-[var(--bg)]'>
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
{children}
</WorkspacePermissionsProvider>
</div>
</Tooltip.Provider>
<div className='flex h-screen w-full bg-[var(--bg)]'>
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
{children}
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</>
)

View File

@@ -72,7 +72,6 @@ const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['va
schedule: 'green',
chat: 'purple',
webhook: 'orange',
a2a: 'teal',
}
interface StatusBadgeProps {

View File

@@ -19,6 +19,7 @@ export type CommandId =
| 'clear-terminal-console'
| 'focus-toolbar-search'
| 'clear-notifications'
| 'fit-to-view'
/**
* Static metadata for a global command.
@@ -104,6 +105,11 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
shortcut: 'Mod+E',
allowInEditable: false,
},
'fit-to-view': {
id: 'fit-to-view',
shortcut: 'Mod+Shift+F',
allowInEditable: false,
},
}
/**

View File

@@ -1,5 +1,6 @@
'use client'
import type { RefObject } from 'react'
import {
Popover,
PopoverAnchor,
@@ -7,14 +8,48 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { BlockContextMenuProps } from './types'
/**
* Block information for context menu actions
*/
export interface BlockInfo {
id: string
type: string
enabled: boolean
horizontalHandles: boolean
parentId?: string
parentType?: string
}
/**
* Props for BlockMenu component
*/
export interface BlockMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
selectedBlocks: BlockInfo[]
onCopy: () => void
onPaste: () => void
onDuplicate: () => void
onDelete: () => void
onToggleEnabled: () => void
onToggleHandles: () => void
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
hasClipboard?: boolean
showRemoveFromSubflow?: boolean
disableEdit?: boolean
}
/**
* Context menu for workflow block(s).
* Displays block-specific actions in a popover at right-click position.
* Supports multi-selection - actions apply to all selected blocks.
*/
export function BlockContextMenu({
export function BlockMenu({
isOpen,
position,
menuRef,
@@ -32,7 +67,7 @@ export function BlockContextMenu({
hasClipboard = false,
showRemoveFromSubflow = false,
disableEdit = false,
}: BlockContextMenuProps) {
}: BlockMenuProps) {
const isSingleBlock = selectedBlocks.length === 1
const allEnabled = selectedBlocks.every((b) => b.enabled)

View File

@@ -0,0 +1,2 @@
export type { BlockInfo, BlockMenuProps } from './block-menu'
export { BlockMenu } from './block-menu'

View File

@@ -1,5 +1,6 @@
'use client'
import type { RefObject } from 'react'
import {
Popover,
PopoverAnchor,
@@ -7,13 +8,40 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { PaneContextMenuProps } from './types'
/**
* Context menu for workflow canvas pane.
* Props for CanvasMenu component
*/
export interface CanvasMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
onUndo: () => void
onRedo: () => void
onPaste: () => void
onAddBlock: () => void
onAutoLayout: () => void
onFitToView: () => void
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onInvite: () => void
isVariablesOpen?: boolean
isChatOpen?: boolean
hasClipboard?: boolean
disableEdit?: boolean
disableAdmin?: boolean
canUndo?: boolean
canRedo?: boolean
isInvitationsDisabled?: boolean
}
/**
* Context menu for workflow canvas.
* Displays canvas-level actions when right-clicking empty space.
*/
export function PaneContextMenu({
export function CanvasMenu({
isOpen,
position,
menuRef,
@@ -23,6 +51,7 @@ export function PaneContextMenu({
onPaste,
onAddBlock,
onAutoLayout,
onFitToView,
onOpenLogs,
onToggleVariables,
onToggleChat,
@@ -35,7 +64,7 @@ export function PaneContextMenu({
canUndo = false,
canRedo = false,
isInvitationsDisabled = false,
}: PaneContextMenuProps) {
}: CanvasMenuProps) {
return (
<Popover
open={isOpen}
@@ -113,6 +142,14 @@ export function PaneContextMenu({
<span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>
</PopoverItem>
<PopoverItem
onClick={() => {
onFitToView()
onClose()
}}
>
Fit to View
</PopoverItem>
{/* Navigation actions */}
<PopoverDivider />

View File

@@ -0,0 +1,2 @@
export type { CanvasMenuProps } from './canvas-menu'
export { CanvasMenu } from './canvas-menu'

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