Compare commits

..

2 Commits

Author SHA1 Message Date
waleed
5ef91e56b8 fixed cred selector 2026-03-25 09:14:42 -07:00
waleed
1b25460a62 feat(agents): added dedicated agents tab, credentials/mcp/tools modal dispatch, ui updates 2026-03-24 15:41:41 -07:00
384 changed files with 10637 additions and 23617 deletions

View File

@@ -1,825 +0,0 @@
---
name: add-block
description: Create or update a Sim integration block with correct subBlocks, conditions, dependsOn, modes, canonicalParamId usage, outputs, and tool wiring. Use when working on `apps/sim/blocks/blocks/{service}.ts` or aligning a block with its tools.
---
# 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'
import { getScopesForService } from '@/lib/oauth/utils'
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 service key
requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils
placeholder: 'Select account',
required: true,
}
```
**Scopes:** Always use `getScopesForService(serviceId)` from `@/lib/oauth/utils` for `requiredScopes`. Never hardcode scope arrays — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
### 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',
}
```
## File Input Handling
When your block accepts file uploads, use the basic/advanced mode pattern with `normalizeFileInput`.
### Basic/Advanced File Pattern
```typescript
// Basic mode: Visual file upload
{
id: 'uploadFile',
title: 'File',
type: 'file-upload',
canonicalParamId: 'file', // Both map to 'file' param
placeholder: 'Upload file',
mode: 'basic',
multiple: false,
required: true,
condition: { field: 'operation', value: 'upload' },
},
// Advanced mode: Reference from other blocks
{
id: 'fileRef',
title: 'File',
type: 'short-input',
canonicalParamId: 'file', // Both map to 'file' param
placeholder: 'Reference file (e.g., {{file_block.output}})',
mode: 'advanced',
required: true,
condition: { field: 'operation', value: 'upload' },
},
```
**Critical constraints:**
- `canonicalParamId` must NOT match any subblock's `id` in the same block
- Values are stored under subblock `id`, not `canonicalParamId`
### Normalizing File Input in tools.config
Use `normalizeFileInput` to handle all input variants:
```typescript
import { normalizeFileInput } from '@/blocks/utils'
tools: {
access: ['service_upload'],
config: {
tool: (params) => {
// Check all field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy)
const normalizedFile = normalizeFileInput(
params.uploadFile || params.fileRef || params.fileContent,
{ single: true }
)
if (normalizedFile) {
params.file = normalizedFile
}
return `service_${params.operation}`
},
},
}
```
**Why this pattern?**
- Values come through as `params.uploadFile` or `params.fileRef` (the subblock IDs)
- `canonicalParamId` only controls UI/schema mapping, not runtime values
- `normalizeFileInput` handles JSON strings from advanced mode template resolution
### File Input Types in `inputs`
Use `type: 'json'` for file inputs:
```typescript
inputs: {
uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' },
fileRef: { type: 'json', description: 'File reference from previous block' },
// Legacy field for backwards compatibility
fileContent: { type: 'string', description: 'Legacy: base64 encoded content' },
}
```
### Multiple Files
For multiple file uploads:
```typescript
{
id: 'attachments',
title: 'Attachments',
type: 'file-upload',
multiple: true, // Allow multiple files
maxSize: 25, // Max size in MB per file
acceptedTypes: 'image/*,application/pdf,.doc,.docx',
}
// In tools.config:
const normalizedFiles = normalizeFileInput(
params.attachments || params.attachmentRefs,
// No { single: true } - returns array
)
if (normalizedFiles) {
params.files = normalizedFiles
}
```
## 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
**Important:** `tools.config.tool` runs during serialization before variable resolution. Put `Number()` and other type coercions in `tools.config.params` instead, which runs at execution time after variables are resolved.
**Preferred:** Use tool names directly as dropdown option IDs to avoid switch cases:
```typescript
// Dropdown options use tool IDs directly
options: [
{ label: 'Create', id: 'service_create' },
{ label: 'Read', id: 'service_read' },
]
// Tool selector just returns the operation value
tool: (params) => 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' },
},
}
```
### Typed JSON Outputs
When using `type: 'json'` and you know the object shape in advance, **describe the inner fields in the description** so downstream blocks know what properties are available. For well-known, stable objects, use nested output definitions instead:
```typescript
outputs: {
// BAD: Opaque json with no info about what's inside
plan: { type: 'json', description: 'Zone plan information' },
// GOOD: Describe the known fields in the description
plan: {
type: 'json',
description: 'Zone plan information (id, name, price, currency, frequency, is_subscribed)',
},
// BEST: Use nested output definition when the shape is stable and well-known
plan: {
id: { type: 'string', description: 'Plan identifier' },
name: { type: 'string', description: 'Plan name' },
price: { type: 'number', description: 'Plan price' },
currency: { type: 'string', description: 'Price currency' },
},
}
```
Use the nested pattern when:
- The object has a small, stable set of fields (< 10)
- Downstream blocks will commonly access specific properties
- The API response shape is well-documented and unlikely to change
Use `type: 'json'` with a descriptive string when:
- The object has many fields or a dynamic shape
- It represents a list/array of items
- The shape varies by operation
## 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'
import { getScopesForService } from '@/lib/oauth/utils'
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',
requiredScopes: getScopesForService('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.
## Icon Requirement
If the icon doesn't already exist in `@/components/icons.tsx`, **do NOT search for it yourself**. After completing the block, ask the user to provide the SVG:
```
The block is complete, but I need an icon for {Service}.
Please provide the SVG and I'll convert it to a React component.
You can usually find this in the service's brand/press kit page, or copy it from their website.
```
## Advanced Mode for Optional Fields
Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes:
- Pagination tokens
- Time range filters (start/end time)
- Sort order options
- Reply settings
- Rarely used IDs (e.g., reply-to tweet ID, quote tweet ID)
- Max results / limits
```typescript
{
id: 'startTime',
title: 'Start Time',
type: 'short-input',
placeholder: 'ISO 8601 timestamp',
condition: { field: 'operation', value: ['search', 'list'] },
mode: 'advanced', // Rarely used, hide from basic view
}
```
## WandConfig for Complex Inputs
Use `wandConfig` for fields that are hard to fill out manually, such as timestamps, comma-separated lists, and complex query strings. This gives users an AI-assisted input experience.
```typescript
// Timestamps - use generationType: 'timestamp' to inject current date context
{
id: 'startTime',
title: 'Start Time',
type: 'short-input',
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.',
generationType: 'timestamp',
},
}
// Comma-separated lists - simple prompt without generationType
{
id: 'mediaIds',
title: 'Media IDs',
type: 'short-input',
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate a comma-separated list of media IDs. Return ONLY the comma-separated values.',
},
}
```
## Naming Convention
All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MUST use `snake_case` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase.
## 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` and `requiredScopes: getScopesForService(serviceId)`
- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
- [ ] Tools.access lists all tool IDs (snake_case)
- [ ] Tools.config.tool returns correct tool ID (snake_case)
- [ ] Outputs match tool outputs
- [ ] Block registered in registry.ts
- [ ] If icon missing: asked user to provide SVG
- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread
- [ ] Optional/rarely-used fields set to `mode: 'advanced'`
- [ ] Timestamps and complex inputs have `wandConfig` enabled
## Final Validation (Required)
After creating the block, you MUST validate it against every tool it references:
1. **Read every tool definition** that appears in `tools.access` — do not skip any
2. **For each tool, verify the block has correct:**
- SubBlock inputs that cover all required tool params (with correct `condition` to show for that operation)
- SubBlock input types that match the tool param types (e.g., dropdown for enums, short-input for strings)
- `tools.config.params` correctly maps subBlock IDs to tool param names (if they differ)
- Type coercions in `tools.config.params` for any params that need conversion (Number(), Boolean(), JSON.parse())
3. **Verify block outputs** cover the key fields returned by all tools
4. **Verify conditions** — each subBlock should only show for the operations that actually use it

View File

@@ -1,5 +0,0 @@
interface:
display_name: "Add Block"
short_description: "Build a Sim block definition"
brand_color: "#2563EB"
default_prompt: "Use $add-block to create or update the block for a Sim integration."

View File

@@ -1,437 +0,0 @@
---
name: add-connector
description: Add or update a Sim knowledge base connector for syncing documents from an external source, including auth mode, config fields, pagination, document mapping, tags, and registry wiring. Use when working in `apps/sim/connectors/{service}/` or adding a new external document source.
---
# Add Connector Skill
You are an expert at adding knowledge base connectors to Sim. A connector syncs documents from an external source (Confluence, Google Drive, Notion, etc.) into a knowledge base.
## Your Task
When the user asks you to create a connector:
1. Use Context7 or WebFetch to read the service's API documentation
2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth)
3. Create the connector directory and config
4. Register it in the connector registry
## Directory Structure
Create files in `apps/sim/connectors/{service}/`:
```
connectors/{service}/
├── index.ts # Barrel export
└── {service}.ts # ConnectorConfig definition
```
## Authentication
Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`):
```typescript
type ConnectorAuthConfig =
| { mode: 'oauth'; provider: OAuthService; requiredScopes?: string[] }
| { mode: 'apiKey'; label?: string; placeholder?: string }
```
### OAuth mode
For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The `provider` must match an `OAuthService`. The modal shows a credential picker and handles token refresh automatically.
### API key mode
For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`.
## ConnectorConfig Structure
### OAuth connector example
```typescript
import { createLogger } from '@sim/logger'
import { {Service}Icon } from '@/components/icons'
import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
const logger = createLogger('{Service}Connector')
export const {service}Connector: ConnectorConfig = {
id: '{service}',
name: '{Service}',
description: 'Sync documents from {Service} into your knowledge base',
version: '1.0.0',
icon: {Service}Icon,
auth: {
mode: 'oauth',
provider: '{service}', // Must match OAuthService in lib/oauth/types.ts
requiredScopes: ['read:...'],
},
configFields: [
// Rendered dynamically by the add-connector modal UI
// Supports 'short-input' and 'dropdown' types
],
listDocuments: async (accessToken, sourceConfig, cursor) => {
// Paginate via cursor, extract text, compute SHA-256 hash
// Return { documents: ExternalDocument[], nextCursor?, hasMore }
},
getDocument: async (accessToken, sourceConfig, externalId) => {
// Return ExternalDocument or null
},
validateConfig: async (accessToken, sourceConfig) => {
// Return { valid: true } or { valid: false, error: 'message' }
},
// Optional: map source metadata to semantic tag keys (translated to slots by sync engine)
mapTags: (metadata) => {
// Return Record<string, unknown> with keys matching tagDefinitions[].id
},
}
```
### API key connector example
```typescript
export const {service}Connector: ConnectorConfig = {
id: '{service}',
name: '{Service}',
description: 'Sync documents from {Service} into your knowledge base',
version: '1.0.0',
icon: {Service}Icon,
auth: {
mode: 'apiKey',
label: 'API Key', // Shown above the input field
placeholder: 'Enter your {Service} API key', // Input placeholder
},
configFields: [ /* ... */ ],
listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ },
getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ },
validateConfig: async (accessToken, sourceConfig) => { /* ... */ },
}
```
## ConfigField Types
The add-connector modal renders these automatically — no custom UI needed.
Three field types are supported: `short-input`, `dropdown`, and `selector`.
```typescript
// Text input
{
id: 'domain',
title: 'Domain',
type: 'short-input',
placeholder: 'yoursite.example.com',
required: true,
}
// Dropdown (static options)
{
id: 'contentType',
title: 'Content Type',
type: 'dropdown',
required: false,
options: [
{ label: 'Pages only', id: 'page' },
{ label: 'Blog posts only', id: 'blogpost' },
{ label: 'All content', id: 'all' },
],
}
```
## Dynamic Selectors (Canonical Pairs)
Use `type: 'selector'` to fetch options dynamically from the existing selector registry (`hooks/selectors/registry.ts`). Selectors are always paired with a manual fallback input using the **canonical pair** pattern — a `selector` field (basic mode) and a `short-input` field (advanced mode) linked by `canonicalParamId`.
The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by `canonicalParamId`.
### Rules
1. **Every selector field MUST have a canonical pair** — a corresponding `short-input` (or `dropdown`) field with the same `canonicalParamId` and `mode: 'advanced'`.
2. **`required` must be set identically on both fields** in a pair. If the selector is required, the manual input must also be required.
3. **`canonicalParamId` must match the key the connector expects in `sourceConfig`** (e.g. `baseId`, `channel`, `teamId`). The advanced field's `id` should typically match `canonicalParamId`.
4. **`dependsOn` references the selector field's `id`**, not the `canonicalParamId`. The modal propagates dependency clearing across canonical siblings automatically — changing either field in a parent pair clears dependent children.
### Selector canonical pair example (Airtable base → table cascade)
```typescript
configFields: [
// Base: selector (basic) + manual (advanced)
{
id: 'baseSelector',
title: 'Base',
type: 'selector',
selectorKey: 'airtable.bases', // Must exist in hooks/selectors/registry.ts
canonicalParamId: 'baseId',
mode: 'basic',
placeholder: 'Select a base',
required: true,
},
{
id: 'baseId',
title: 'Base ID',
type: 'short-input',
canonicalParamId: 'baseId',
mode: 'advanced',
placeholder: 'e.g. appXXXXXXXXXXXXXX',
required: true,
},
// Table: selector depends on base (basic) + manual (advanced)
{
id: 'tableSelector',
title: 'Table',
type: 'selector',
selectorKey: 'airtable.tables',
canonicalParamId: 'tableIdOrName',
mode: 'basic',
dependsOn: ['baseSelector'], // References the selector field ID
placeholder: 'Select a table',
required: true,
},
{
id: 'tableIdOrName',
title: 'Table Name or ID',
type: 'short-input',
canonicalParamId: 'tableIdOrName',
mode: 'advanced',
placeholder: 'e.g. Tasks',
required: true,
},
// Non-selector fields stay as-is
{ id: 'maxRecords', title: 'Max Records', type: 'short-input', ... },
]
```
### Selector with domain dependency (Jira/Confluence pattern)
When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references that field's `id` directly. The `domain` field's value maps to `SelectorContext.domain` automatically via `SELECTOR_CONTEXT_FIELDS`.
```typescript
configFields: [
{
id: 'domain',
title: 'Jira Domain',
type: 'short-input',
placeholder: 'yoursite.atlassian.net',
required: true,
},
{
id: 'projectSelector',
title: 'Project',
type: 'selector',
selectorKey: 'jira.projects',
canonicalParamId: 'projectKey',
mode: 'basic',
dependsOn: ['domain'],
placeholder: 'Select a project',
required: true,
},
{
id: 'projectKey',
title: 'Project Key',
type: 'short-input',
canonicalParamId: 'projectKey',
mode: 'advanced',
placeholder: 'e.g. ENG, PROJ',
required: true,
},
]
```
### How `dependsOn` maps to `SelectorContext`
The connector selector field builds a `SelectorContext` from dependency values. For the mapping to work, each dependency's `canonicalParamId` (or field `id` for non-canonical fields) must exist in `SELECTOR_CONTEXT_FIELDS` (`lib/workflows/subblocks/context.ts`):
```
oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId,
siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId
```
### Available selector keys
Check `hooks/selectors/types.ts` for the full `SelectorKey` union. Common ones for connectors:
| SelectorKey | Context Deps | Returns |
|-------------|-------------|---------|
| `airtable.bases` | credential | Base ID + name |
| `airtable.tables` | credential, `baseId` | Table ID + name |
| `slack.channels` | credential | Channel ID + name |
| `gmail.labels` | credential | Label ID + name |
| `google.calendar` | credential | Calendar ID + name |
| `linear.teams` | credential | Team ID + name |
| `linear.projects` | credential, `teamId` | Project ID + name |
| `jira.projects` | credential, `domain` | Project key + name |
| `confluence.spaces` | credential, `domain` | Space key + name |
| `notion.databases` | credential | Database ID + name |
| `asana.workspaces` | credential | Workspace GID + name |
| `microsoft.teams` | credential | Team ID + name |
| `microsoft.channels` | credential, `teamId` | Channel ID + name |
| `webflow.sites` | credential | Site ID + name |
| `outlook.folders` | credential | Folder ID + name |
## ExternalDocument Shape
Every document returned from `listDocuments`/`getDocument` must include:
```typescript
{
externalId: string // Source-specific unique ID
title: string // Document title
content: string // Extracted plain text
mimeType: 'text/plain' // Always text/plain (content is extracted)
contentHash: string // SHA-256 of content (change detection)
sourceUrl?: string // Link back to original (stored on document record)
metadata?: Record<string, unknown> // Source-specific data (fed to mapTags)
}
```
## Content Hashing (Required)
The sync engine uses content hashes for change detection:
```typescript
async function computeContentHash(content: string): Promise<string> {
const data = new TextEncoder().encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
}
```
## tagDefinitions — Declared Tag Definitions
Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes.
On connector creation, slots are **dynamically assigned** via `getNextAvailableSlot` — connectors never hardcode slot names.
```typescript
tagDefinitions: [
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
{ id: 'version', displayName: 'Version', fieldType: 'number' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
],
```
Each entry has:
- `id`: Semantic key matching a key returned by `mapTags` (e.g. `'labels'`, `'version'`)
- `displayName`: Human-readable name shown in the UI (e.g. "Labels", "Last Modified")
- `fieldType`: `'text'` | `'number'` | `'date'` | `'boolean'` — determines which slot pool to draw from
Users can opt out of specific tags in the modal. Disabled IDs are stored in `sourceConfig.disabledTagIds`.
The assigned mapping (`semantic id → slot`) is stored in `sourceConfig.tagSlotMapping`.
## mapTags — Metadata to Semantic Keys
Maps source metadata to semantic tag keys. Required if `tagDefinitions` is set.
The sync engine calls this automatically and translates semantic keys to actual DB slots
using the `tagSlotMapping` stored on the connector.
Return keys must match the `id` values declared in `tagDefinitions`.
```typescript
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
// Validate arrays before casting — metadata may be malformed
const labels = Array.isArray(metadata.labels) ? (metadata.labels as string[]) : []
if (labels.length > 0) result.labels = labels.join(', ')
// Validate numbers — guard against NaN
if (metadata.version != null) {
const num = Number(metadata.version)
if (!Number.isNaN(num)) result.version = num
}
// Validate dates — guard against Invalid Date
if (typeof metadata.lastModified === 'string') {
const date = new Date(metadata.lastModified)
if (!Number.isNaN(date.getTime())) result.lastModified = date
}
return result
}
```
## External API Calls — Use `fetchWithRetry`
All external API calls must use `fetchWithRetry` from `@/lib/knowledge/documents/utils` instead of raw `fetch()`. This provides exponential backoff with retries on 429/502/503/504 errors. It returns a standard `Response` — all `.ok`, `.json()`, `.text()` checks work unchanged.
For `validateConfig` (user-facing, called on save), pass `VALIDATE_RETRY_OPTIONS` to cap wait time at ~7s. Background operations (`listDocuments`, `getDocument`) use the built-in defaults (5 retries, ~31s max).
```typescript
import { VALIDATE_RETRY_OPTIONS, fetchWithRetry } from '@/lib/knowledge/documents/utils'
// Background sync — use defaults
const response = await fetchWithRetry(url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
})
// validateConfig — tighter retry budget
const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS)
```
## sourceUrl
If `ExternalDocument.sourceUrl` is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path).
## Sync Engine Behavior (Do Not Modify)
The sync engine (`lib/knowledge/connectors/sync-engine.ts`) is connector-agnostic. It:
1. Calls `listDocuments` with pagination until `hasMore` is false
2. Compares `contentHash` to detect new/changed/unchanged documents
3. Stores `sourceUrl` and calls `mapTags` on insert/update automatically
4. Handles soft-delete of removed documents
5. Resolves access tokens automatically — OAuth tokens are refreshed, API keys are decrypted from the `encryptedApiKey` column
You never need to modify the sync engine when adding a connector.
## Icon
The `icon` field on `ConnectorConfig` is used throughout the UI — in the connector list, the add-connector modal, and as the document icon in the knowledge base table (replacing the generic file type icon for connector-sourced documents). The icon is read from `CONNECTOR_REGISTRY[connectorType].icon` at runtime — no separate icon map to maintain.
If the service already has an icon in `apps/sim/components/icons.tsx` (from a tool integration), reuse it. Otherwise, ask the user to provide the SVG.
## Registering
Add one line to `apps/sim/connectors/registry.ts`:
```typescript
import { {service}Connector } from '@/connectors/{service}'
export const CONNECTOR_REGISTRY: ConnectorRegistry = {
// ... existing connectors ...
{service}: {service}Connector,
}
```
## Reference Implementations
- **OAuth**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching
- **API key**: `apps/sim/connectors/fireflies/fireflies.ts` — GraphQL API with Bearer token auth
## Checklist
- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig
- [ ] Created `connectors/{service}/index.ts` barrel export
- [ ] **Auth configured correctly:**
- OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts`
- API key: `auth.label` and `auth.placeholder` set appropriately
- [ ] **Selector fields configured correctly (if applicable):**
- Every `type: 'selector'` field has a canonical pair (`short-input` or `dropdown` with same `canonicalParamId` and `mode: 'advanced'`)
- `required` is identical on both fields in each canonical pair
- `selectorKey` exists in `hooks/selectors/registry.ts`
- `dependsOn` references selector field IDs (not `canonicalParamId`)
- Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS`
- [ ] `listDocuments` handles pagination and computes content hashes
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
- [ ] `metadata` includes source-specific data for tag mapping
- [ ] `tagDefinitions` declared for each semantic key returned by `mapTags`
- [ ] `mapTags` implemented if source has useful metadata (labels, dates, versions)
- [ ] `validateConfig` verifies the source is accessible
- [ ] All external API calls use `fetchWithRetry` (not raw `fetch`)
- [ ] All optional config fields validated in `validateConfig`
- [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG)
- [ ] Registered in `connectors/registry.ts`

View File

@@ -1,5 +0,0 @@
interface:
display_name: "Add Connector"
short_description: "Build a Sim knowledge connector"
brand_color: "#0F766E"
default_prompt: "Use $add-connector to add or update a Sim knowledge connector for a service."

View File

@@ -1,760 +0,0 @@
---
name: add-integration
description: Add a complete Sim integration from API docs, covering tools, block, icon, optional triggers, registrations, and integration conventions. Use when introducing a new service under `apps/sim/tools`, `apps/sim/blocks`, and `apps/sim/triggers`.
---
# 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
- When using `type: 'json'` and you know the object shape, define `properties` with the inner fields so downstream consumers know the structure. Only use bare `type: 'json'` when the shape is truly dynamic
## 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'
import { getScopesForService } from '@/lib/oauth/utils'
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}',
requiredScopes: getScopesForService('{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 Canonical Param Rules:**
- `canonicalParamId` must NOT match any subblock's `id` in the block
- `canonicalParamId` must be unique per operation/condition context
- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical 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
- **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes)
- **Inputs section:** Must list canonical param IDs (e.g., `fileId`), NOT raw subblock IDs (e.g., `fileSelector`, `manualFileId`)
- **Params function:** Must use canonical param IDs, NOT raw subblock IDs (raw IDs are deleted after canonical transformation)
## 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 user-provided SVG */}
</svg>
)
}
```
### Getting Icons
**Do NOT search for icons yourself.** At the end of implementation, ask the user to provide the SVG:
```
I've completed the integration. Before I can add the icon, please provide the SVG for {Service}.
You can usually find this in the service's brand/press kit page, or copy it from their website.
Paste the SVG code here and I'll convert it to a React component.
```
Once the user provides the SVG:
1. Extract the SVG paths/content
2. Create a React component that spreads props
3. Ensure viewBox is preserved from the original SVG
## 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 with `requiredScopes: getScopesForService('{service}')`
- [ ] 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()`
### OAuth Scopes (if OAuth service)
- [ ] Defined scopes in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS`
- [ ] Added scope descriptions in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode)
- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode)
### Icon
- [ ] Asked user to provide SVG
- [ ] 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
### Final Validation (Required)
- [ ] Read every tool file and cross-referenced inputs/outputs against the API docs
- [ ] Verified block subBlocks cover all required tool params with correct conditions
- [ ] Verified block outputs match what the tools actually return
- [ ] Verified `tools.config.params` correctly maps and coerces all param types
## 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. Register everything
5. Generate docs
6. Ask you for the Stripe icon SVG
[Proceed with implementation...]
[After completing steps 1-5...]
I've completed the Stripe integration. Before I can add the icon, please provide the SVG for Stripe.
You can usually find this in the service's brand/press kit page, or copy it from their website.
Paste the SVG code here and I'll convert it to a React component.
```
## File Handling
When your integration handles file uploads or downloads, follow these patterns to work with `UserFile` objects consistently.
### What is a UserFile?
A `UserFile` is the standard file representation in Sim:
```typescript
interface UserFile {
id: string // Unique identifier
name: string // Original filename
url: string // Presigned URL for download
size: number // File size in bytes
type: string // MIME type (e.g., 'application/pdf')
base64?: string // Optional base64 content (if small file)
key?: string // Internal storage key
context?: object // Storage context metadata
}
```
### File Input Pattern (Uploads)
For tools that accept file uploads, **always route through an internal API endpoint** rather than calling external APIs directly. This ensures proper file content retrieval.
#### 1. Block SubBlocks for File Input
Use the basic/advanced mode pattern:
```typescript
// Basic mode: File upload UI
{
id: 'uploadFile',
title: 'File',
type: 'file-upload',
canonicalParamId: 'file', // Maps to 'file' param
placeholder: 'Upload file',
mode: 'basic',
multiple: false,
required: true,
condition: { field: 'operation', value: 'upload' },
},
// Advanced mode: Reference from previous block
{
id: 'fileRef',
title: 'File',
type: 'short-input',
canonicalParamId: 'file', // Same canonical param
placeholder: 'Reference file (e.g., {{file_block.output}})',
mode: 'advanced',
required: true,
condition: { field: 'operation', value: 'upload' },
},
```
**Critical:** `canonicalParamId` must NOT match any subblock `id`.
#### 2. Normalize File Input in Block Config
In `tools.config.tool`, use `normalizeFileInput` to handle all input variants:
```typescript
import { normalizeFileInput } from '@/blocks/utils'
tools: {
config: {
tool: (params) => {
// Normalize file from basic (uploadFile), advanced (fileRef), or legacy (fileContent)
const normalizedFile = normalizeFileInput(
params.uploadFile || params.fileRef || params.fileContent,
{ single: true }
)
if (normalizedFile) {
params.file = normalizedFile
}
return `{service}_${params.operation}`
},
},
}
```
#### 3. Create Internal API Route
Create `apps/sim/app/api/tools/{service}/{action}/route.ts`:
```typescript
import { createLogger } from '@sim/logger'
import { NextResponse, type NextRequest } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
const logger = createLogger('{Service}UploadAPI')
const RequestSchema = z.object({
accessToken: z.string(),
file: FileInputSchema.optional().nullable(),
// Legacy field for backwards compatibility
fileContent: z.string().optional().nullable(),
// ... other params
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
let fileBuffer: Buffer
let fileName: string
// Prefer UserFile input, fall back to legacy base64
if (data.file) {
const userFiles = processFilesToUserFiles([data.file as RawFileInput], requestId, logger)
if (userFiles.length === 0) {
return NextResponse.json({ success: false, error: 'Invalid file' }, { status: 400 })
}
const userFile = userFiles[0]
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
fileName = userFile.name
} else if (data.fileContent) {
// Legacy: base64 string (backwards compatibility)
fileBuffer = Buffer.from(data.fileContent, 'base64')
fileName = 'file'
} else {
return NextResponse.json({ success: false, error: 'File required' }, { status: 400 })
}
// Now call external API with fileBuffer
const response = await fetch('https://api.{service}.com/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${data.accessToken}` },
body: new Uint8Array(fileBuffer), // Convert Buffer for fetch
})
// ... handle response
}
```
#### 4. Update Tool to Use Internal Route
```typescript
export const {service}UploadTool: ToolConfig<Params, Response> = {
id: '{service}_upload',
// ...
params: {
file: { type: 'file', required: false, visibility: 'user-or-llm' },
fileContent: { type: 'string', required: false, visibility: 'hidden' }, // Legacy
},
request: {
url: '/api/tools/{service}/upload', // Internal route
method: 'POST',
body: (params) => ({
accessToken: params.accessToken,
file: params.file,
fileContent: params.fileContent,
}),
},
}
```
### File Output Pattern (Downloads)
For tools that return files, use `FileToolProcessor` to store files and return `UserFile` objects.
#### In Tool transformResponse
```typescript
import { FileToolProcessor } from '@/executor/utils/file-tool-processor'
transformResponse: async (response, context) => {
const data = await response.json()
// Process file outputs to UserFile objects
const fileProcessor = new FileToolProcessor(context)
const file = await fileProcessor.processFileData({
data: data.content, // base64 or buffer
mimeType: data.mimeType,
filename: data.filename,
})
return {
success: true,
output: { file },
}
}
```
#### In API Route (for complex file handling)
```typescript
// Return file data that FileToolProcessor can handle
return NextResponse.json({
success: true,
output: {
file: {
data: base64Content,
mimeType: 'application/pdf',
filename: 'document.pdf',
},
},
})
```
### Key Helpers Reference
| Helper | Location | Purpose |
|--------|----------|---------|
| `normalizeFileInput` | `@/blocks/utils` | Normalize file params in block config |
| `processFilesToUserFiles` | `@/lib/uploads/utils/file-utils` | Convert raw inputs to UserFile[] |
| `downloadFileFromStorage` | `@/lib/uploads/utils/file-utils.server` | Get file Buffer from UserFile |
| `FileToolProcessor` | `@/executor/utils/file-tool-processor` | Process tool output files |
| `isUserFile` | `@/lib/core/utils/user-file` | Type guard for UserFile objects |
| `FileInputSchema` | `@/lib/uploads/utils/file-schemas` | Zod schema for file validation |
### Advanced Mode for Optional Fields
Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. Examples: pagination tokens, time range filters, sort order, max results, reply settings.
### WandConfig for Complex Inputs
Use `wandConfig` for fields that are hard to fill out manually:
- **Timestamps**: Use `generationType: 'timestamp'` to inject current date context into the AI prompt
- **JSON arrays**: Use `generationType: 'json-object'` for structured data
- **Complex queries**: Use a descriptive prompt explaining the expected format
```typescript
{
id: 'startTime',
title: 'Start Time',
type: 'short-input',
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
generationType: 'timestamp',
},
}
```
### OAuth Scopes (Centralized System)
Scopes are maintained in a single source of truth and reused everywhere:
1. **Define scopes** in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
2. **Add descriptions** in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for the OAuth modal UI
3. **Reference in auth.ts** using `getCanonicalScopesForProvider(providerId)` from `@/lib/oauth/utils`
4. **Reference in blocks** using `getScopesForService(serviceId)` from `@/lib/oauth/utils`
**Never hardcode scope arrays** in `auth.ts` or block `requiredScopes`. Always import from the centralized source.
```typescript
// In auth.ts (Better Auth config)
scopes: getCanonicalScopesForProvider('{service}'),
// In block credential sub-block
requiredScopes: getScopesForService('{service}'),
```
### Common Gotchas
1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration
2. **All tool IDs MUST be snake_case** - `stripe_create_payment`, not `stripeCreatePayment`. This applies to tool `id` fields, registry keys, `tools.access` arrays, and `tools.config.tool` return values
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
7. **Never pass Buffer directly to fetch** - Convert to `new Uint8Array(buffer)` for TypeScript compatibility
8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility
9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields
10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled
11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts
12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`

View File

@@ -1,5 +0,0 @@
interface:
display_name: "Add Integration"
short_description: "Build a full Sim integration"
brand_color: "#7C3AED"
default_prompt: "Use $add-integration to add a complete Sim integration for a service."

View File

@@ -1,321 +0,0 @@
---
name: add-tools
description: Create or update Sim tool configurations from service API docs, including typed params, request mapping, response transforms, outputs, and registry entries. Use when working in `apps/sim/tools/{service}/` or fixing tool definitions for an integration.
---
# 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, only use hidden for oauth accessToken)
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token',
},
// User-only params (credentials, api key, IDs user must provide)
someId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'The ID of the resource',
},
// User-or-LLM params (everything else, 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, api keys, account-specific IDs)
- `'user-or-llm'` - User provides OR LLM can compute (search queries, content, filters, most fall into this category)
### 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,
},
```
### Typed JSON Outputs
When using `type: 'json'` and you know the object shape in advance, **always define the inner structure** using `properties` so downstream consumers know what fields are available:
```typescript
// BAD: Opaque json with no info about what's inside
metadata: {
type: 'json',
description: 'Response metadata',
},
// GOOD: Define the known properties
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' },
},
},
```
For arrays of objects, define the item structure:
```typescript
items: {
type: 'array',
description: 'List of items',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Item ID' },
name: { type: 'string', description: 'Item name' },
},
},
},
```
Only use bare `type: 'json'` without `properties` when the shape is truly dynamic or unknown.
## 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)
## Naming Convention
All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase for tool IDs.
## Checklist Before Finishing
- [ ] All tool IDs use snake_case
- [ ] 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
## Final Validation (Required)
After creating all tools, you MUST validate every tool before finishing:
1. **Read every tool file** you created — do not skip any
2. **Cross-reference with the API docs** to verify:
- All required params are marked `required: true`
- All optional params are marked `required: false`
- Param types match the API (string, number, boolean, json)
- Request URL, method, headers, and body match the API spec
- `transformResponse` extracts the correct fields from the API response
- All output fields match what the API actually returns
- No fields are missing from outputs that the API provides
- No extra fields are defined in outputs that the API doesn't return
3. **Verify consistency** across tools:
- Shared types in `types.ts` match all tools that use them
- Tool IDs in the barrel export match the tool file definitions
- Error handling is consistent (error checks, meaningful messages)

View File

@@ -1,5 +0,0 @@
interface:
display_name: "Add Tools"
short_description: "Build Sim tools from API docs"
brand_color: "#EA580C"
default_prompt: "Use $add-tools to create or update Sim tool definitions from service API docs."

View File

@@ -1,708 +0,0 @@
---
name: add-trigger
description: Create or update Sim webhook triggers using the generic trigger builder, service-specific setup instructions, outputs, and registry wiring. Use when working in `apps/sim/triggers/{service}/` or adding webhook support to an integration.
---
# 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,5 +0,0 @@
interface:
display_name: "Add Trigger"
short_description: "Build Sim webhook triggers"
brand_color: "#DC2626"
default_prompt: "Use $add-trigger to create or update webhook triggers for a Sim integration."

View File

@@ -1,316 +0,0 @@
---
name: validate-connector
description: Audit an existing Sim knowledge base connector against the service API docs and repository conventions, then report and fix issues in auth, config fields, pagination, document mapping, tags, and registry entries. Use when validating or repairing code in `apps/sim/connectors/{service}/`.
---
# Validate Connector Skill
You are an expert auditor for Sim knowledge base connectors. Your job is to thoroughly validate that an existing connector is correct, complete, and follows all conventions.
## Your Task
When the user asks you to validate a connector:
1. Read the service's API documentation (via Context7 or WebFetch)
2. Read the connector implementation, OAuth config, and registry entries
3. Cross-reference everything against the API docs and Sim conventions
4. Report all issues found, grouped by severity (critical, warning, suggestion)
5. Fix all issues after reporting them
## Step 1: Gather All Files
Read **every** file for the connector — do not skip any:
```
apps/sim/connectors/{service}/{service}.ts # Connector implementation
apps/sim/connectors/{service}/index.ts # Barrel export
apps/sim/connectors/registry.ts # Connector registry entry
apps/sim/connectors/types.ts # ConnectorConfig interface, ExternalDocument, etc.
apps/sim/connectors/utils.ts # Shared utilities (computeContentHash, htmlToPlainText, etc.)
apps/sim/lib/oauth/oauth.ts # OAUTH_PROVIDERS — single source of truth for scopes
apps/sim/lib/oauth/utils.ts # getCanonicalScopesForProvider, getScopesForService, SCOPE_DESCRIPTIONS
apps/sim/lib/oauth/types.ts # OAuthService union type
apps/sim/components/icons.tsx # Icon definition for the service
```
If the connector uses selectors, also read:
```
apps/sim/hooks/selectors/registry.ts # Selector key definitions
apps/sim/hooks/selectors/types.ts # SelectorKey union type
apps/sim/lib/workflows/subblocks/context.ts # SELECTOR_CONTEXT_FIELDS
```
## Step 2: Pull API Documentation
Fetch the official API docs for the service. This is the **source of truth** for:
- Endpoint URLs, HTTP methods, and auth headers
- Required vs optional parameters
- Parameter types and allowed values
- Response shapes and field names
- Pagination patterns (cursor, offset, next token)
- Rate limits and error formats
- OAuth scopes and their meanings
Use Context7 (resolve-library-id → query-docs) or WebFetch to retrieve documentation. If both fail, note which claims are based on training knowledge vs verified docs.
## Step 3: Validate API Endpoints
For **every** API call in the connector (`listDocuments`, `getDocument`, `validateConfig`, and any helper functions), verify against the API docs:
### URLs and Methods
- [ ] Base URL is correct for the service's API version
- [ ] Endpoint paths match the API docs exactly
- [ ] HTTP method is correct (GET, POST, PUT, PATCH, DELETE)
- [ ] Path parameters are correctly interpolated and URI-encoded where needed
- [ ] Query parameters use correct names and formats per the API docs
### Headers
- [ ] Authorization header uses the correct format:
- OAuth: `Authorization: Bearer ${accessToken}`
- API Key: correct header name per the service's docs
- [ ] `Content-Type` is set for POST/PUT/PATCH requests
- [ ] Any service-specific headers are present (e.g., `Notion-Version`, `Dropbox-API-Arg`)
- [ ] No headers are sent that the API doesn't support or silently ignores
### Request Bodies
- [ ] POST/PUT body fields match API parameter names exactly
- [ ] Required fields are always sent
- [ ] Optional fields are conditionally included (not sent as `null` or empty unless the API expects that)
- [ ] Field value types match API expectations (string vs number vs boolean)
### Input Sanitization
- [ ] User-controlled values interpolated into query strings are properly escaped:
- OData `$filter`: single quotes escaped with `''` (e.g., `externalId.replace(/'/g, "''")`)
- SOQL: single quotes escaped with `\'`
- GraphQL variables: passed as variables, not interpolated into query strings
- URL path segments: `encodeURIComponent()` applied
- [ ] URL-type config fields (e.g., `siteUrl`, `instanceUrl`) are normalized:
- Strip `https://` / `http://` prefix if the API expects bare domains
- Strip trailing `/`
- Apply `.trim()` before validation
### Response Parsing
- [ ] Response structure is correctly traversed (e.g., `data.results` vs `data.items` vs `data`)
- [ ] Field names extracted match what the API actually returns
- [ ] Nullable fields are handled with `?? null` or `|| undefined`
- [ ] Error responses are checked before accessing data fields
## Step 4: Validate OAuth Scopes (if OAuth connector)
Scopes must be correctly declared and sufficient for all API calls the connector makes.
### Connector requiredScopes
- [ ] `requiredScopes` in the connector's `auth` config lists all scopes needed by the connector
- [ ] Each scope in `requiredScopes` is a real, valid scope recognized by the service's API
- [ ] No invalid, deprecated, or made-up scopes are listed
- [ ] No unnecessary excess scopes beyond what the connector actually needs
### Scope Subset Validation (CRITICAL)
- [ ] Every scope in `requiredScopes` exists in the OAuth provider's `scopes` array in `lib/oauth/oauth.ts`
- [ ] Find the provider in `OAUTH_PROVIDERS[providerGroup].services[serviceId].scopes`
- [ ] Verify: `requiredScopes``OAUTH_PROVIDERS scopes` (every required scope is present in the provider config)
- [ ] If a required scope is NOT in the provider config, flag as **critical** — the connector will fail at runtime
### Scope Sufficiency
For each API endpoint the connector calls:
- [ ] Identify which scopes are required per the API docs
- [ ] Verify those scopes are included in the connector's `requiredScopes`
- [ ] If the connector calls endpoints requiring scopes not in `requiredScopes`, flag as **warning**
### Token Refresh Config
- [ ] Check the `getOAuthTokenRefreshConfig` function in `lib/oauth/oauth.ts` for this provider
- [ ] `useBasicAuth` matches the service's token exchange requirements
- [ ] `supportsRefreshTokenRotation` matches whether the service issues rotating refresh tokens
- [ ] Token endpoint URL is correct
## Step 5: Validate Pagination
### listDocuments Pagination
- [ ] Cursor/pagination parameter name matches the API docs
- [ ] Response pagination field is correctly extracted (e.g., `next_cursor`, `nextPageToken`, `@odata.nextLink`, `offset`)
- [ ] `hasMore` is correctly determined from the response
- [ ] `nextCursor` is correctly passed back for the next page
- [ ] `maxItems` / `maxRecords` cap is correctly applied across pages using `syncContext.totalDocsFetched`
- [ ] Page size is within the API's allowed range (not exceeding max page size)
- [ ] Last page precision: when a `maxItems` cap exists, the final page request uses `Math.min(PAGE_SIZE, remaining)` to avoid fetching more records than needed
- [ ] No off-by-one errors in pagination tracking
- [ ] The connector does NOT hit known API pagination limits silently (e.g., HubSpot search 10k cap)
### Pagination State Across Pages
- [ ] `syncContext` is used to cache state across pages (user names, field maps, instance URLs, portal IDs, etc.)
- [ ] Cached state in `syncContext` is correctly initialized on first page and reused on subsequent pages
## Step 6: Validate Data Transformation
### ExternalDocument Construction
- [ ] `externalId` is a stable, unique identifier from the source API
- [ ] `title` is extracted from the correct field and has a sensible fallback (e.g., `'Untitled'`)
- [ ] `content` is plain text — HTML content is stripped using `htmlToPlainText` from `@/connectors/utils`
- [ ] `mimeType` is `'text/plain'`
- [ ] `contentHash` is computed using `computeContentHash` from `@/connectors/utils`
- [ ] `sourceUrl` is a valid, complete URL back to the original resource (not relative)
- [ ] `metadata` contains all fields referenced by `mapTags` and `tagDefinitions`
### Content Extraction
- [ ] Rich text / HTML fields are converted to plain text before indexing
- [ ] Important content is not silently dropped (e.g., nested blocks, table cells, code blocks)
- [ ] Content is not silently truncated without logging a warning
- [ ] Empty/blank documents are properly filtered out
- [ ] Size checks use `Buffer.byteLength(text, 'utf8')` not `text.length` when comparing against byte-based limits (e.g., `MAX_FILE_SIZE` in bytes)
## Step 7: Validate Tag Definitions and mapTags
### tagDefinitions
- [ ] Each `tagDefinition` has an `id`, `displayName`, and `fieldType`
- [ ] `fieldType` matches the actual data type: `'text'` for strings, `'number'` for numbers, `'date'` for dates, `'boolean'` for booleans
- [ ] Every `id` in `tagDefinitions` is returned by `mapTags`
- [ ] No `tagDefinition` references a field that `mapTags` never produces
### mapTags
- [ ] Return keys match `tagDefinition` `id` values exactly
- [ ] Date values are properly parsed using `parseTagDate` from `@/connectors/utils`
- [ ] Array values are properly joined using `joinTagArray` from `@/connectors/utils`
- [ ] Number values are validated (not `NaN`)
- [ ] Metadata field names accessed in `mapTags` match what `listDocuments`/`getDocument` store in `metadata`
## Step 8: Validate Config Fields and Validation
### configFields
- [ ] Every field has `id`, `title`, `type`
- [ ] `required` is set explicitly (not omitted)
- [ ] Dropdown fields have `options` with `label` and `id` for each option
- [ ] Selector fields follow the canonical pair pattern:
- A `type: 'selector'` field with `selectorKey`, `canonicalParamId`, `mode: 'basic'`
- A `type: 'short-input'` field with the same `canonicalParamId`, `mode: 'advanced'`
- `required` is identical on both fields in the pair
- [ ] `selectorKey` values exist in the selector registry
- [ ] `dependsOn` references selector field `id` values, not `canonicalParamId`
### validateConfig
- [ ] Validates all required fields are present before making API calls
- [ ] Validates optional numeric fields (checks `Number.isNaN`, positive values)
- [ ] Makes a lightweight API call to verify access (e.g., fetch 1 record, get profile)
- [ ] Uses `VALIDATE_RETRY_OPTIONS` for retry budget
- [ ] Returns `{ valid: true }` on success
- [ ] Returns `{ valid: false, error: 'descriptive message' }` on failure
- [ ] Catches exceptions and returns user-friendly error messages
- [ ] Does NOT make expensive calls (full data listing, large queries)
## Step 9: Validate getDocument
- [ ] Fetches a single document by `externalId`
- [ ] Returns `null` for 404 / not found (does not throw)
- [ ] Returns the same `ExternalDocument` shape as `listDocuments`
- [ ] Handles all content types that `listDocuments` can produce (e.g., if `listDocuments` returns both pages and blogposts, `getDocument` must handle both — not hardcode one endpoint)
- [ ] Forwards `syncContext` if it needs cached state (user names, field maps, etc.)
- [ ] Error handling is graceful (catches, logs, returns null or throws with context)
- [ ] Does not redundantly re-fetch data already included in the initial API response (e.g., if comments come back with the post, don't fetch them again separately)
## Step 10: Validate General Quality
### fetchWithRetry Usage
- [ ] All external API calls use `fetchWithRetry` from `@/lib/knowledge/documents/utils`
- [ ] No raw `fetch()` calls to external APIs
- [ ] `VALIDATE_RETRY_OPTIONS` used in `validateConfig`
- [ ] If `validateConfig` calls a shared helper (e.g., `linearGraphQL`, `resolveId`), that helper must accept and forward `retryOptions` to `fetchWithRetry`
- [ ] Default retry options used in `listDocuments`/`getDocument`
### API Efficiency
- [ ] APIs that support field selection (e.g., `$select`, `sysparm_fields`, `fields`) should request only the fields the connector needs — in both `listDocuments` AND `getDocument`
- [ ] No redundant API calls: if a helper already fetches data (e.g., site metadata), callers should reuse the result instead of making a second call for the same information
- [ ] Sequential per-item API calls (fetching details for each document in a loop) should be batched with `Promise.all` and a concurrency limit of 3-5
### Error Handling
- [ ] Individual document failures are caught and logged without aborting the sync
- [ ] API error responses include status codes in error messages
- [ ] No unhandled promise rejections in concurrent operations
### Concurrency
- [ ] Concurrent API calls use reasonable batch sizes (3-5 is typical)
- [ ] No unbounded `Promise.all` over large arrays
### Logging
- [ ] Uses `createLogger` from `@sim/logger` (not `console.log`)
- [ ] Logs sync progress at `info` level
- [ ] Logs errors at `warn` or `error` level with context
### Registry
- [ ] Connector is exported from `connectors/{service}/index.ts`
- [ ] Connector is registered in `connectors/registry.ts`
- [ ] Registry key matches the connector's `id` field
## Step 11: Report and Fix
### Report Format
Group findings by severity:
**Critical** (will cause runtime errors, data loss, or auth failures):
- Wrong API endpoint URL or HTTP method
- Invalid or missing OAuth scopes (not in provider config)
- Incorrect response field mapping (accessing wrong path)
- SOQL/query fields that don't exist on the target object
- Pagination that silently hits undocumented API limits
- Missing error handling that would crash the sync
- `requiredScopes` not a subset of OAuth provider scopes
- Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping
**Warning** (incorrect behavior, data quality issues, or convention violations):
- HTML content not stripped via `htmlToPlainText`
- `getDocument` not forwarding `syncContext`
- `getDocument` hardcoded to one content type when `listDocuments` returns multiple (e.g., only pages but not blogposts)
- Missing `tagDefinition` for metadata fields returned by `mapTags`
- Incorrect `useBasicAuth` or `supportsRefreshTokenRotation` in token refresh config
- Invalid scope names that the API doesn't recognize (even if silently ignored)
- Private resources excluded from name-based lookup despite scopes being available
- Silent data truncation without logging
- Size checks using `text.length` (character count) instead of `Buffer.byteLength` (byte count) for byte-based limits
- URL-type config fields not normalized (protocol prefix, trailing slashes cause API failures)
- `VALIDATE_RETRY_OPTIONS` not threaded through helper functions called by `validateConfig`
**Suggestion** (minor improvements):
- Missing incremental sync support despite API supporting it
- Overly broad scopes that could be narrowed (not wrong, but could be tighter)
- Source URL format could be more specific
- Missing `orderBy` for deterministic pagination
- Redundant API calls that could be cached in `syncContext`
- Sequential per-item API calls that could be batched with `Promise.all` (concurrency 3-5)
- API supports field selection but connector fetches all fields (e.g., missing `$select`, `sysparm_fields`, `fields`)
- `getDocument` re-fetches data already included in the initial API response (e.g., comments returned with post)
- Last page of pagination requests full `PAGE_SIZE` when fewer records remain (`Math.min(PAGE_SIZE, remaining)`)
### Fix All Issues
After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity.
### Validation Output
After fixing, confirm:
1. `bun run lint` passes
2. TypeScript compiles clean
3. Re-read all modified files to verify fixes are correct
## Checklist Summary
- [ ] Read connector implementation, types, utils, registry, and OAuth config
- [ ] Pulled and read official API documentation for the service
- [ ] Validated every API endpoint URL, method, headers, and body against API docs
- [ ] Validated input sanitization: no query/filter injection, URL fields normalized
- [ ] Validated OAuth scopes: `requiredScopes` ⊆ OAuth provider `scopes` in `oauth.ts`
- [ ] Validated each scope is real and recognized by the service's API
- [ ] Validated scopes are sufficient for all API endpoints the connector calls
- [ ] Validated token refresh config (`useBasicAuth`, `supportsRefreshTokenRotation`)
- [ ] Validated pagination: cursor names, page sizes, hasMore logic, no silent caps
- [ ] Validated data transformation: plain text extraction, HTML stripping, content hashing
- [ ] Validated tag definitions match mapTags output, correct fieldTypes
- [ ] Validated config fields: canonical pairs, selector keys, required flags
- [ ] Validated validateConfig: lightweight check, error messages, retry options
- [ ] Validated getDocument: null on 404, all content types handled, no redundant re-fetches, syncContext forwarding
- [ ] Validated fetchWithRetry used for all external calls (no raw fetch), VALIDATE_RETRY_OPTIONS threaded through helpers
- [ ] Validated API efficiency: field selection used, no redundant calls, sequential fetches batched
- [ ] Validated error handling: graceful failures, no unhandled rejections
- [ ] Validated logging: createLogger, no console.log
- [ ] Validated registry: correct export, correct key
- [ ] Reported all issues grouped by severity
- [ ] Fixed all critical and warning issues
- [ ] Ran `bun run lint` after fixes
- [ ] Verified TypeScript compiles clean

View File

@@ -1,5 +0,0 @@
interface:
display_name: "Validate Connector"
short_description: "Audit a Sim knowledge connector"
brand_color: "#059669"
default_prompt: "Use $validate-connector to audit and fix a Sim knowledge connector against its API docs."

View File

@@ -1,289 +0,0 @@
---
name: validate-integration
description: Audit an existing Sim integration against the service API docs and repository conventions, then report and fix issues across tools, blocks, outputs, OAuth scopes, triggers, and registry entries. Use when validating or repairing a service integration under `apps/sim/tools`, `apps/sim/blocks`, or `apps/sim/triggers`.
---
# Validate Integration Skill
You are an expert auditor for Sim integrations. Your job is to thoroughly validate that an existing integration is correct, complete, and follows all conventions.
## Your Task
When the user asks you to validate an integration:
1. Read the service's API documentation (via WebFetch or Context7)
2. Read every tool, the block, and registry entries
3. Cross-reference everything against the API docs and Sim conventions
4. Report all issues found, grouped by severity (critical, warning, suggestion)
5. Fix all issues after reporting them
## Step 1: Gather All Files
Read **every** file for the integration — do not skip any:
```
apps/sim/tools/{service}/ # All tool files, types.ts, index.ts
apps/sim/blocks/blocks/{service}.ts # Block definition
apps/sim/tools/registry.ts # Tool registry entries for this service
apps/sim/blocks/registry.ts # Block registry entry for this service
apps/sim/components/icons.tsx # Icon definition
apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider()
apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes
apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
```
## Step 2: Pull API Documentation
Fetch the official API docs for the service. This is the **source of truth** for:
- Endpoint URLs, HTTP methods, and auth headers
- Required vs optional parameters
- Parameter types and allowed values
- Response shapes and field names
- Pagination patterns (which param name, which response field)
- Rate limits and error formats
## Step 3: Validate Tools
For **every** tool file, check:
### Tool ID and Naming
- [ ] Tool ID uses `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`, `slack_send_message`)
- [ ] Tool `name` is human-readable (e.g., `'X Create Tweet'`)
- [ ] Tool `description` is a concise one-liner describing what it does
- [ ] Tool `version` is set (`'1.0.0'` or `'2.0.0'` for V2)
### Params
- [ ] All required API params are marked `required: true`
- [ ] All optional API params are marked `required: false`
- [ ] Every param has explicit `required: true` or `required: false` — never omitted
- [ ] Param types match the API (`'string'`, `'number'`, `'boolean'`, `'json'`)
- [ ] Visibility is correct:
- `'hidden'` — ONLY for OAuth access tokens and system-injected params
- `'user-only'` — for API keys, credentials, and account-specific IDs the user must provide
- `'user-or-llm'` — for everything else (search queries, content, filters, IDs that could come from other blocks)
- [ ] Every param has a `description` that explains what it does
### Request
- [ ] URL matches the API endpoint exactly (correct base URL, path segments, path params)
- [ ] HTTP method matches the API spec (GET, POST, PUT, PATCH, DELETE)
- [ ] Headers include correct auth pattern:
- OAuth: `Authorization: Bearer ${params.accessToken}`
- API Key: correct header name and format per the service's docs
- [ ] `Content-Type` header is set for POST/PUT/PATCH requests
- [ ] Body sends all required fields and only includes optional fields when provided
- [ ] For GET requests with query params: URL is constructed correctly with query string
- [ ] ID fields in URL paths are `.trim()`-ed to prevent copy-paste whitespace errors
- [ ] Path params use template literals correctly: `` `https://api.service.com/v1/${params.id.trim()}` ``
### Response / transformResponse
- [ ] Correctly parses the API response (`await response.json()`)
- [ ] Extracts the right fields from the response structure (e.g., `data.data` vs `data` vs `data.results`)
- [ ] All nullable fields use `?? null`
- [ ] All optional arrays use `?? []`
- [ ] Error cases are handled: checks for missing/empty data and returns meaningful error
- [ ] Does NOT do raw JSON dumps — extracts meaningful, individual fields
### Outputs
- [ ] All output fields match what the API actually returns
- [ ] No fields are missing that the API provides and users would commonly need
- [ ] No phantom fields defined that the API doesn't return
- [ ] `optional: true` is set on fields that may not exist in all responses
- [ ] When using `type: 'json'` and the shape is known, `properties` defines the inner fields
- [ ] When using `type: 'array'`, `items` defines the item structure with `properties`
- [ ] Field descriptions are accurate and helpful
### Types (types.ts)
- [ ] Has param interfaces for every tool (e.g., `XCreateTweetParams`)
- [ ] Has response interfaces for every tool (extending `ToolResponse`)
- [ ] Optional params use `?` in the interface (e.g., `replyTo?: string`)
- [ ] Field names in types match actual API field names
- [ ] Shared response types are properly reused (e.g., `XTweetResponse` shared across tweet tools)
### Barrel Export (index.ts)
- [ ] Every tool is exported
- [ ] All types are re-exported (`export * from './types'`)
- [ ] No orphaned exports (tools that don't exist)
### Tool Registry (tools/registry.ts)
- [ ] Every tool is imported and registered
- [ ] Registry keys use snake_case and match tool IDs exactly
- [ ] Entries are in alphabetical order within the file
## Step 4: Validate Block
### Block ↔ Tool Alignment (CRITICAL)
This is the most important validation — the block must be perfectly aligned with every tool it references.
For **each tool** in `tools.access`:
- [ ] The operation dropdown has an option whose ID matches the tool ID (or the `tools.config.tool` function correctly maps to it)
- [ ] Every **required** tool param (except `accessToken`) has a corresponding subBlock input that is:
- Shown when that operation is selected (correct `condition`)
- Marked as `required: true` (or conditionally required)
- [ ] Every **optional** tool param has a corresponding subBlock input (or is intentionally omitted if truly never needed)
- [ ] SubBlock `id` values are unique across the entire block — no duplicates even across different conditions
- [ ] The `tools.config.tool` function returns the correct tool ID for every possible operation value
- [ ] The `tools.config.params` function correctly maps subBlock IDs to tool param names when they differ
### SubBlocks
- [ ] Operation dropdown lists ALL tool operations available in `tools.access`
- [ ] Dropdown option labels are human-readable and descriptive
- [ ] Conditions use correct syntax:
- Single value: `{ field: 'operation', value: 'x_create_tweet' }`
- Multiple values (OR): `{ field: 'operation', value: ['x_create_tweet', 'x_delete_tweet'] }`
- Negation: `{ field: 'operation', value: 'delete', not: true }`
- Compound: `{ field: 'op', value: 'send', and: { field: 'type', value: 'dm' } }`
- [ ] Condition arrays include ALL operations that use that field — none missing
- [ ] `dependsOn` is set for fields that need other values (selectors depending on credential, cascading dropdowns)
- [ ] SubBlock types match tool param types:
- Enum/fixed options → `dropdown`
- Free text → `short-input`
- Long text/content → `long-input`
- True/false → `dropdown` with Yes/No options (not `switch` unless purely UI toggle)
- Credentials → `oauth-input` with correct `serviceId`
- [ ] Dropdown `value: () => 'default'` is set for dropdowns with a sensible default
### Advanced Mode
- [ ] Optional, rarely-used fields are set to `mode: 'advanced'`:
- Pagination tokens / next tokens
- Time range filters (start/end time)
- Sort order / direction options
- Max results / per page limits
- Reply settings / threading options
- Rarely used IDs (reply-to, quote-tweet, etc.)
- Exclude filters
- [ ] **Required** fields are NEVER set to `mode: 'advanced'`
- [ ] Fields that users fill in most of the time are NOT set to `mode: 'advanced'`
### WandConfig
- [ ] Timestamp fields have `wandConfig` with `generationType: 'timestamp'`
- [ ] Comma-separated list fields have `wandConfig` with a descriptive prompt
- [ ] Complex filter/query fields have `wandConfig` with format examples in the prompt
- [ ] All `wandConfig` prompts end with "Return ONLY the [format] - no explanations, no extra text."
- [ ] `wandConfig.placeholder` describes what to type in natural language
### Tools Config
- [ ] `tools.access` lists **every** tool ID the block can use — none missing
- [ ] `tools.config.tool` returns the correct tool ID for each operation
- [ ] Type coercions are in `tools.config.params` (runs at execution time), NOT in `tools.config.tool` (runs at serialization time before variable resolution)
- [ ] `tools.config.params` handles:
- `Number()` conversion for numeric params that come as strings from inputs
- `Boolean` / string-to-boolean conversion for toggle params
- Empty string → `undefined` conversion for optional dropdown values
- Any subBlock ID → tool param name remapping
- [ ] No `Number()`, `JSON.parse()`, or other coercions in `tools.config.tool` — these would destroy dynamic references like `<Block.output>`
### Block Outputs
- [ ] Outputs cover the key fields returned by ALL tools (not just one operation)
- [ ] Output types are correct (`'string'`, `'number'`, `'boolean'`, `'json'`)
- [ ] `type: 'json'` outputs either:
- Describe inner fields in the description string (GOOD): `'User profile (id, name, username, bio)'`
- Use nested output definitions (BEST): `{ id: { type: 'string' }, name: { type: 'string' } }`
- [ ] No opaque `type: 'json'` with vague descriptions like `'Response data'`
- [ ] Outputs that only appear for certain operations use `condition` if supported, or document which operations return them
### Block Metadata
- [ ] `type` is snake_case (e.g., `'x'`, `'cloudflare'`)
- [ ] `name` is human-readable (e.g., `'X'`, `'Cloudflare'`)
- [ ] `description` is a concise one-liner
- [ ] `longDescription` provides detail for docs
- [ ] `docsLink` points to `'https://docs.sim.ai/tools/{service}'`
- [ ] `category` is `'tools'`
- [ ] `bgColor` uses the service's brand color hex
- [ ] `icon` references the correct icon component from `@/components/icons`
- [ ] `authMode` is set correctly (`AuthMode.OAuth` or `AuthMode.ApiKey`)
- [ ] Block is registered in `blocks/registry.ts` alphabetically
### Block Inputs
- [ ] `inputs` section lists all subBlock params that the block accepts
- [ ] Input types match the subBlock types
- [ ] When using `canonicalParamId`, inputs list the canonical ID (not the raw subBlock IDs)
## Step 5: Validate OAuth Scopes (if OAuth service)
Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
- [ ] Scopes defined in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
- [ ] `auth.ts` uses `getCanonicalScopesForProvider(providerId)` — NOT a hardcoded array
- [ ] Block `requiredScopes` uses `getScopesForService(serviceId)` — NOT a hardcoded array
- [ ] No hardcoded scope arrays in `auth.ts` or block files (should all use utility functions)
- [ ] Each scope has a human-readable description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
- [ ] No excess scopes that aren't needed by any tool
## Step 6: Validate Pagination Consistency
If any tools support pagination:
- [ ] Pagination param names match the API docs (e.g., `pagination_token` vs `next_token` vs `cursor`)
- [ ] Different API endpoints that use different pagination param names have separate subBlocks in the block
- [ ] Pagination response fields (`nextToken`, `cursor`, etc.) are included in tool outputs
- [ ] Pagination subBlocks are set to `mode: 'advanced'`
## Step 7: Validate Error Handling
- [ ] `transformResponse` checks for error conditions before accessing data
- [ ] Error responses include meaningful messages (not just generic "failed")
- [ ] HTTP error status codes are handled (check `response.ok` or status codes)
## Step 8: Report and Fix
### Report Format
Group findings by severity:
**Critical** (will cause runtime errors or incorrect behavior):
- Wrong endpoint URL or HTTP method
- Missing required params or wrong `required` flag
- Incorrect response field mapping (accessing wrong path in response)
- Missing error handling that would cause crashes
- Tool ID mismatch between tool file, registry, and block `tools.access`
- OAuth scopes missing in `auth.ts` that tools need
- `tools.config.tool` returning wrong tool ID for an operation
- Type coercions in `tools.config.tool` instead of `tools.config.params`
**Warning** (follows conventions incorrectly or has usability issues):
- Optional field not set to `mode: 'advanced'`
- Missing `wandConfig` on timestamp/complex fields
- Wrong `visibility` on params (e.g., `'hidden'` instead of `'user-or-llm'`)
- Missing `optional: true` on nullable outputs
- Opaque `type: 'json'` without property descriptions
- Missing `.trim()` on ID fields in request URLs
- Missing `?? null` on nullable response fields
- Block condition array missing an operation that uses that field
- Hardcoded scope arrays instead of using `getScopesForService()` / `getCanonicalScopesForProvider()`
- Missing scope description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
**Suggestion** (minor improvements):
- Better description text
- Inconsistent naming across tools
- Missing `longDescription` or `docsLink`
- Pagination fields that could benefit from `wandConfig`
### Fix All Issues
After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity.
### Validation Output
After fixing, confirm:
1. `bun run lint` passes with no fixes needed
2. TypeScript compiles clean (no type errors)
3. Re-read all modified files to verify fixes are correct
## Checklist Summary
- [ ] Read ALL tool files, block, types, index, and registries
- [ ] Pulled and read official API documentation
- [ ] Validated every tool's ID, params, request, response, outputs, and types against API docs
- [ ] Validated block ↔ tool alignment (every tool param has a subBlock, every condition is correct)
- [ ] Validated advanced mode on optional/rarely-used fields
- [ ] Validated wandConfig on timestamps and complex inputs
- [ ] Validated tools.config mapping, tool selector, and type coercions
- [ ] Validated block outputs match what tools return, with typed JSON where possible
- [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
- [ ] Validated pagination consistency across tools and block
- [ ] Validated error handling (error checks, meaningful messages)
- [ ] Validated registry entries (tools and block, alphabetical, correct imports)
- [ ] Reported all issues grouped by severity
- [ ] Fixed all critical and warning issues
- [ ] Ran `bun run lint` after fixes
- [ ] Verified TypeScript compiles clean

View File

@@ -1,5 +0,0 @@
interface:
display_name: "Validate Integration"
short_description: "Audit a Sim service integration"
brand_color: "#B45309"
default_prompt: "Use $validate-integration to audit and fix a Sim integration against its API docs."

383
AGENTS.md
View File

@@ -1,383 +0,0 @@
# Sim Development Guidelines
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
## Global Standards
- **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`
- **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments
- **Styling**: Never update global styles. Keep all styling local to components
- **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx`
## Architecture
### Core Principles
1. Single Responsibility: Each component, hook, store has one clear purpose
2. Composition Over Complexity: Break down complex logic into smaller pieces
3. Type Safety First: TypeScript interfaces for all props, state, return types
4. Predictable State: Zustand for global state, useState for UI-only concerns
### Root Structure
```
apps/sim/
├── app/ # Next.js app router (pages, API routes)
├── blocks/ # Block definitions and registry
├── components/ # Shared UI (emcn/, ui/)
├── executor/ # Workflow execution engine
├── hooks/ # Shared hooks (queries/, selectors/)
├── lib/ # App-wide utilities
├── providers/ # LLM provider integrations
├── stores/ # Zustand stores
├── tools/ # Tool definitions
└── triggers/ # Trigger definitions
```
### Naming Conventions
- Components: PascalCase (`WorkflowList`)
- Hooks: `use` prefix (`useWorkflowOperations`)
- Files: kebab-case (`workflow-list.tsx`)
- Stores: `stores/feature/store.ts`
- Constants: SCREAMING_SNAKE_CASE
- Interfaces: PascalCase with suffix (`WorkflowListProps`)
## Imports
**Always use absolute imports.** Never use relative imports.
```typescript
// ✓ Good
import { useWorkflowStore } from '@/stores/workflows/store'
// ✗ Bad
import { useWorkflowStore } from '../../../stores/workflows/store'
```
Use barrel exports (`index.ts`) when a folder has 3+ exports. Do not re-export from non-barrel files; import directly from the source.
### Import Order
1. React/core libraries
2. External libraries
3. UI components (`@/components/emcn`, `@/components/ui`)
4. Utilities (`@/lib/...`)
5. Stores (`@/stores/...`)
6. Feature imports
7. CSS imports
Use `import type { X }` for type-only imports.
## TypeScript
1. No `any` - Use proper types or `unknown` with type guards
2. Always define props interface for components
3. `as const` for constant objects/arrays
4. Explicit ref types: `useRef<HTMLDivElement>(null)`
## Components
```typescript
'use client' // Only if using hooks
const CONFIG = { SPACING: 8 } as const
interface ComponentProps {
requiredProp: string
optionalProp?: boolean
}
export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
// Order: refs → external hooks → store hooks → custom hooks → state → useMemo → useCallback → useEffect → return
}
```
Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational.
## Hooks
```typescript
interface UseFeatureProps { id: string }
export function useFeature({ id }: UseFeatureProps) {
const idRef = useRef(id)
const [data, setData] = useState<Data | null>(null)
useEffect(() => { idRef.current = id }, [id])
const fetchData = useCallback(async () => { ... }, []) // Empty deps when using refs
return { data, fetchData }
}
```
## Zustand Stores
Stores live in `stores/`. Complex stores split into `store.ts` + `types.ts`.
```typescript
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const initialState = { items: [] as Item[] }
export const useFeatureStore = create<FeatureState>()(
devtools(
(set, get) => ({
...initialState,
setItems: (items) => set({ items }),
reset: () => set(initialState),
}),
{ name: 'feature-store' }
)
)
```
Use `devtools` middleware. Use `persist` only when data should survive reload with `partialize` to persist only necessary state.
## React Query
All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations.
### Query Key Factory
Every file must have a hierarchical key factory with an `all` root key and intermediate plural keys for prefix invalidation:
```typescript
export const entityKeys = {
all: ['entity'] as const,
lists: () => [...entityKeys.all, 'list'] as const,
list: (workspaceId?: string) => [...entityKeys.lists(), workspaceId ?? ''] as const,
details: () => [...entityKeys.all, 'detail'] as const,
detail: (id?: string) => [...entityKeys.details(), id ?? ''] as const,
}
```
### Query Hooks
- Every `queryFn` must forward `signal` for request cancellation
- Every query must have an explicit `staleTime`
- Use `keepPreviousData` only on variable-key queries (where params change), never on static keys
```typescript
export function useEntityList(workspaceId?: string) {
return useQuery({
queryKey: entityKeys.list(workspaceId),
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
enabled: Boolean(workspaceId),
staleTime: 60 * 1000,
placeholderData: keepPreviousData, // OK: workspaceId varies
})
}
```
### Mutation Hooks
- Use targeted invalidation (`entityKeys.lists()`) not broad (`entityKeys.all`) when possible
- For optimistic updates: use `onSettled` (not `onSuccess`) for cache reconciliation — `onSettled` fires on both success and error
- Don't include mutation objects in `useCallback` deps — `.mutate()` is stable in TanStack Query v5
```typescript
export function useUpdateEntity() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables) => { /* ... */ },
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) })
const previous = queryClient.getQueryData(entityKeys.detail(variables.id))
queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic */)
return { previous }
},
onError: (_err, variables, context) => {
queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous)
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) })
},
})
}
```
## Styling
Use Tailwind only, no inline styles. Use `cn()` from `@/lib/utils` for conditional classes.
```typescript
<div className={cn('base-classes', isActive && 'active-classes')} />
```
## EMCN Components
Import from `@/components/emcn`, never from subpaths (except CSS files). Use CVA when 2+ variants exist.
## Testing
Use Vitest. Test files: `feature.ts``feature.test.ts`. See `.cursor/rules/sim-testing.mdc` for full details.
### Global Mocks (vitest.setup.ts)
`@sim/db`, `drizzle-orm`, `@sim/logger`, `@/blocks/registry`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior.
### Standard Test Pattern
```typescript
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
import { GET } from '@/app/api/my-route/route'
describe('my route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
})
it('returns data', async () => { ... })
})
```
### Performance Rules
- **NEVER** use `vi.resetModules()` + `vi.doMock()` + `await import()` — use `vi.hoisted()` + `vi.mock()` + static imports
- **NEVER** use `vi.importActual()` — mock everything explicitly
- **NEVER** use `mockAuth()`, `mockConsoleLogger()`, `setupCommonApiMocks()` from `@sim/testing` — they use `vi.doMock()` internally
- **Mock heavy deps** (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them
- **Use `@vitest-environment node`** unless DOM APIs are needed (`window`, `document`, `FormData`)
- **Avoid real timers** — use 1ms delays or `vi.useFakeTimers()`
Use `@sim/testing` mocks/factories over local test data.
## Utils Rules
- Never create `utils.ts` for single consumer - inline it
- Create `utils.ts` when 2+ files need the same helper
- Check existing sources in `lib/` before duplicating
## Adding Integrations
New integrations require: **Tools****Block****Icon** → (optional) **Trigger**
Always look up the service's API docs first.
### 1. Tools (`tools/{service}/`)
```
tools/{service}/
├── index.ts # Barrel export
├── types.ts # Params/response types
└── {action}.ts # Tool implementation
```
**Tool structure:**
```typescript
export const serviceTool: ToolConfig<Params, Response> = {
id: 'service_action',
name: 'Service Action',
description: '...',
version: '1.0.0',
oauth: { required: true, provider: 'service' },
params: { /* ... */ },
request: { url: '/api/tools/service/action', method: 'POST', ... },
transformResponse: async (response) => { /* ... */ },
outputs: { /* ... */ },
}
```
Register in `tools/registry.ts`.
### 2. Block (`blocks/blocks/{service}.ts`)
```typescript
export const ServiceBlock: BlockConfig = {
type: 'service',
name: 'Service',
description: '...',
category: 'tools',
bgColor: '#hexcolor',
icon: ServiceIcon,
subBlocks: [ /* see SubBlock Properties */ ],
tools: { access: ['service_action'], config: { tool: (p) => `service_${p.operation}`, params: (p) => ({ /* type coercions here */ }) } },
inputs: { /* ... */ },
outputs: { /* ... */ },
}
```
Register in `blocks/registry.ts` (alphabetically).
**Important:** `tools.config.tool` runs during serialization (before variable resolution). Never do `Number()` or other type coercions there — dynamic references like `<Block.output>` will be destroyed. Use `tools.config.params` for type coercions (it runs during execution, after variables are resolved).
**SubBlock Properties:**
```typescript
{
id: 'field', title: 'Label', type: 'short-input', placeholder: '...',
required: true, // or condition object
condition: { field: 'op', value: 'send' }, // show/hide
dependsOn: ['credential'], // clear when dep changes
mode: 'basic', // 'basic' | 'advanced' | 'both' | 'trigger'
}
```
**condition examples:**
- `{ field: 'op', value: 'send' }` - show when op === 'send'
- `{ field: 'op', value: ['a','b'] }` - show when op is 'a' OR 'b'
- `{ field: 'op', value: 'x', not: true }` - show when op !== 'x'
- `{ field: 'op', value: 'x', not: true, and: { field: 'type', value: 'dm', not: true } }` - complex
**dependsOn:** `['field']` or `{ all: ['a'], any: ['b', 'c'] }`
**File Input Pattern (basic/advanced mode):**
```typescript
// Basic: file-upload UI
{ id: 'uploadFile', type: 'file-upload', canonicalParamId: 'file', mode: 'basic' },
// Advanced: reference from other blocks
{ id: 'fileRef', type: 'short-input', canonicalParamId: 'file', mode: 'advanced' },
```
In `tools.config.tool`, normalize with:
```typescript
import { normalizeFileInput } from '@/blocks/utils'
const file = normalizeFileInput(params.uploadFile || params.fileRef, { single: true })
if (file) params.file = file
```
For file uploads, create an internal API route (`/api/tools/{service}/upload`) that uses `downloadFileFromStorage` to get file content from `UserFile` objects.
### 3. Icon (`components/icons.tsx`)
```typescript
export function ServiceIcon(props: SVGProps<SVGSVGElement>) {
return <svg {...props}>/* SVG from brand assets */</svg>
}
```
### 4. Trigger (`triggers/{service}/`) - Optional
```
triggers/{service}/
├── index.ts # Barrel export
├── webhook.ts # Webhook handler
└── {event}.ts # Event-specific handlers
```
Register in `triggers/registry.ts`.
### Integration Checklist
- [ ] Look up API docs
- [ ] Create `tools/{service}/` with types and tools
- [ ] Register tools in `tools/registry.ts`
- [ ] Add icon to `components/icons.tsx`
- [ ] Create block in `blocks/blocks/{service}.ts`
- [ ] Register block in `blocks/registry.ts`
- [ ] (Optional) Create and register triggers
- [ ] (If file uploads) Create internal API route with `downloadFileFromStorage`
- [ ] (If file uploads) Use `normalizeFileInput` in block config

View File

@@ -3,7 +3,7 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="apps/sim/public/logo/wordmark.svg">
<source media="(prefers-color-scheme: light)" srcset="apps/sim/public/logo/wordmark-dark.svg">
<img src="apps/sim/public/logo/wordmark-dark.svg" alt="Sim Logo" width="380"/>
<img src="apps/sim/public/logo/wordmark-dark.svg" alt="Sim Logo" width="300"/>
</picture>
</a>
</p>

View File

@@ -6270,17 +6270,6 @@ export function RedisIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function RipplingIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 145.3 109.9' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M17.5,30.5C17.5,18.4,11.4,8.4,0,0h26.5c9.3,7.2,15,18.2,15,30.5c0,12.3-5.7,23.3-15,30.5 c8.6,3.6,13.5,12.4,13.5,25v24H16v-24c0-12-5.7-20.4-16-25C11.4,52.5,17.5,42.6,17.5,30.5 M69.4,30.5c0-12.1-6.1-22.1-17.5-30.5 h26.5c9.3,7.2,15,18.2,15,30.5c0,12.3-5.7,23.3-15,30.5c8.6,3.6,13.5,12.4,13.5,25v24h-24v-24c0-12-5.7-20.4-16-25 C63.3,52.5,69.4,42.6,69.4,30.5 M121.4,30.5c0-12.1-6.1-22.1-17.5-30.5h26.5c9.3,7.2,15,18.2,15,30.5c0,12.3-5.7,23.3-15,30.5 c8.6,3.6,13.5,12.4,13.5,25v24h-24v-24c0-12-5.7-20.4-16-25C115.3,52.5,121.4,42.6,121.4,30.5'
fill='#502D3C'
/>
</svg>
)
}
export function HexIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1450.3 600'>

View File

@@ -133,7 +133,6 @@ import {
ReductoIcon,
ResendIcon,
RevenueCatIcon,
RipplingIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
@@ -307,7 +306,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
reducto_v2: ReductoIcon,
resend: ResendIcon,
revenuecat: RevenueCatIcon,
rippling: RipplingIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,

View File

@@ -41,7 +41,6 @@ Retrieve all users from HubSpot account
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | string | No | Number of results to return \(default: 100, max: 100\) |
| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
#### Output
@@ -54,9 +53,6 @@ Retrieve all users from HubSpot account
| ↳ `primaryTeamId` | string | Primary team ID |
| ↳ `secondaryTeamIds` | array | Secondary team IDs |
| ↳ `superAdmin` | boolean | Whether user is a super admin |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `totalItems` | number | Total number of users returned |
| `success` | boolean | Operation success status |
@@ -234,7 +230,7 @@ Search for contacts in HubSpot using filters, sorting, and queries
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "CONTAINS_TOKEN", "GT"\), and "value" |
| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "CONTAINS"\), and "value" |
| `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) |
| `query` | string | No | Search query string to match against contact name, email, and other text fields |
| `properties` | array | No | Array of HubSpot property names to return \(e.g., \["email", "firstname", "lastname", "phone"\]\) |
@@ -453,7 +449,7 @@ Search for companies in HubSpot using filters, sorting, and queries
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "CONTAINS_TOKEN", "GT"\), and "value" |
| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "CONTAINS"\), and "value" |
| `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) |
| `query` | string | No | Search query string to match against company name, domain, and other text fields |
| `properties` | array | No | Array of HubSpot property names to return \(e.g., \["name", "domain", "industry"\]\) |
@@ -501,747 +497,30 @@ Retrieve all deals from HubSpot account with pagination support
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "dealname,amount,dealstage"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deals` | array | Array of HubSpot deal records |
| ↳ `dealname` | string | Deal name |
| ↳ `amount` | string | Deal amount |
| ↳ `dealstage` | string | Current deal stage |
| ↳ `pipeline` | string | Pipeline the deal is in |
| ↳ `closedate` | string | Expected close date \(ISO 8601\) |
| ↳ `dealtype` | string | Deal type \(New Business, Existing Business, etc.\) |
| ↳ `description` | string | Deal description |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Deal creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| ↳ `num_associated_contacts` | string | Number of associated contacts |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records are available |
| `success` | boolean | Operation success status |
### `hubspot_get_deal`
Retrieve a single deal by ID from HubSpot
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `dealId` | string | Yes | The HubSpot deal ID to retrieve |
| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "dealname,amount,dealstage"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deal` | object | HubSpot deal record |
| ↳ `dealname` | string | Deal name |
| ↳ `amount` | string | Deal amount |
| ↳ `dealstage` | string | Current deal stage |
| ↳ `pipeline` | string | Pipeline the deal is in |
| ↳ `closedate` | string | Expected close date \(ISO 8601\) |
| ↳ `dealtype` | string | Deal type \(New Business, Existing Business, etc.\) |
| ↳ `description` | string | Deal description |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Deal creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| ↳ `num_associated_contacts` | string | Number of associated contacts |
| `dealId` | string | The retrieved deal ID |
| `success` | boolean | Operation success status |
### `hubspot_create_deal`
Create a new deal in HubSpot. Requires at least a dealname property
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `properties` | object | Yes | Deal properties as JSON object. Must include dealname \(e.g., \{"dealname": "New Deal", "amount": "5000", "dealstage": "appointmentscheduled"\}\) |
| `associations` | array | No | Array of associations to create with the deal as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deal` | object | HubSpot deal record |
| ↳ `dealname` | string | Deal name |
| ↳ `amount` | string | Deal amount |
| ↳ `dealstage` | string | Current deal stage |
| ↳ `pipeline` | string | Pipeline the deal is in |
| ↳ `closedate` | string | Expected close date \(ISO 8601\) |
| ↳ `dealtype` | string | Deal type \(New Business, Existing Business, etc.\) |
| ↳ `description` | string | Deal description |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Deal creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| ↳ `num_associated_contacts` | string | Number of associated contacts |
| `dealId` | string | The created deal ID |
| `success` | boolean | Operation success status |
### `hubspot_update_deal`
Update an existing deal in HubSpot by ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `dealId` | string | Yes | The HubSpot deal ID to update |
| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
| `properties` | object | Yes | Deal properties to update as JSON object \(e.g., \{"amount": "10000", "dealstage": "closedwon"\}\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deal` | object | HubSpot deal record |
| ↳ `dealname` | string | Deal name |
| ↳ `amount` | string | Deal amount |
| ↳ `dealstage` | string | Current deal stage |
| ↳ `pipeline` | string | Pipeline the deal is in |
| ↳ `closedate` | string | Expected close date \(ISO 8601\) |
| ↳ `dealtype` | string | Deal type \(New Business, Existing Business, etc.\) |
| ↳ `description` | string | Deal description |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Deal creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| ↳ `num_associated_contacts` | string | Number of associated contacts |
| `dealId` | string | The updated deal ID |
| `success` | boolean | Operation success status |
### `hubspot_search_deals`
Search for deals in HubSpot using filters, sorting, and queries
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "NEQ", "CONTAINS_TOKEN", "NOT_CONTAINS_TOKEN"\), and "value" |
| `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) |
| `query` | string | No | Search query string to match against deal name and other text fields |
| `properties` | array | No | Array of HubSpot property names to return \(e.g., \["dealname", "amount", "dealstage"\]\) |
| `limit` | number | No | Maximum number of results to return \(max 200\) |
| `after` | string | No | Pagination cursor for next page \(from previous response\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deals` | array | Array of HubSpot deal records |
| ↳ `dealname` | string | Deal name |
| ↳ `amount` | string | Deal amount |
| ↳ `dealstage` | string | Current deal stage |
| ↳ `pipeline` | string | Pipeline the deal is in |
| ↳ `closedate` | string | Expected close date \(ISO 8601\) |
| ↳ `dealtype` | string | Deal type \(New Business, Existing Business, etc.\) |
| ↳ `description` | string | Deal description |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Deal creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| ↳ `num_associated_contacts` | string | Number of associated contacts |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records are available |
| `total` | number | Total number of matching deals |
| `success` | boolean | Operation success status |
### `hubspot_list_tickets`
Retrieve all tickets from HubSpot account with pagination support
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "subject,content,hs_ticket_priority"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tickets` | array | Array of HubSpot ticket records |
| ↳ `subject` | string | Ticket subject/name |
| ↳ `content` | string | Ticket content/description |
| ↳ `hs_pipeline` | string | Pipeline the ticket is in |
| ↳ `hs_pipeline_stage` | string | Current pipeline stage |
| ↳ `hs_ticket_priority` | string | Ticket priority \(LOW, MEDIUM, HIGH\) |
| ↳ `hs_ticket_category` | string | Ticket category |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Ticket creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records are available |
| `success` | boolean | Operation success status |
### `hubspot_get_ticket`
Retrieve a single ticket by ID from HubSpot
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `ticketId` | string | Yes | The HubSpot ticket ID to retrieve |
| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "subject,content,hs_ticket_priority"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ticket` | object | HubSpot ticket record |
| ↳ `subject` | string | Ticket subject/name |
| ↳ `content` | string | Ticket content/description |
| ↳ `hs_pipeline` | string | Pipeline the ticket is in |
| ↳ `hs_pipeline_stage` | string | Current pipeline stage |
| ↳ `hs_ticket_priority` | string | Ticket priority \(LOW, MEDIUM, HIGH\) |
| ↳ `hs_ticket_category` | string | Ticket category |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Ticket creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `ticketId` | string | The retrieved ticket ID |
| `success` | boolean | Operation success status |
### `hubspot_create_ticket`
Create a new ticket in HubSpot. Requires subject and hs_pipeline_stage properties
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `properties` | object | Yes | Ticket properties as JSON object. Must include subject and hs_pipeline_stage \(e.g., \{"subject": "Support request", "hs_pipeline_stage": "1", "hs_ticket_priority": "HIGH"\}\) |
| `associations` | array | No | Array of associations to create with the ticket as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ticket` | object | HubSpot ticket record |
| ↳ `subject` | string | Ticket subject/name |
| ↳ `content` | string | Ticket content/description |
| ↳ `hs_pipeline` | string | Pipeline the ticket is in |
| ↳ `hs_pipeline_stage` | string | Current pipeline stage |
| ↳ `hs_ticket_priority` | string | Ticket priority \(LOW, MEDIUM, HIGH\) |
| ↳ `hs_ticket_category` | string | Ticket category |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Ticket creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `ticketId` | string | The created ticket ID |
| `success` | boolean | Operation success status |
### `hubspot_update_ticket`
Update an existing ticket in HubSpot by ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `ticketId` | string | Yes | The HubSpot ticket ID to update |
| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
| `properties` | object | Yes | Ticket properties to update as JSON object \(e.g., \{"subject": "Updated subject", "hs_ticket_priority": "HIGH"\}\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ticket` | object | HubSpot ticket record |
| ↳ `subject` | string | Ticket subject/name |
| ↳ `content` | string | Ticket content/description |
| ↳ `hs_pipeline` | string | Pipeline the ticket is in |
| ↳ `hs_pipeline_stage` | string | Current pipeline stage |
| ↳ `hs_ticket_priority` | string | Ticket priority \(LOW, MEDIUM, HIGH\) |
| ↳ `hs_ticket_category` | string | Ticket category |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Ticket creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `ticketId` | string | The updated ticket ID |
| `success` | boolean | Operation success status |
### `hubspot_search_tickets`
Search for tickets in HubSpot using filters, sorting, and queries
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "NEQ", "CONTAINS_TOKEN", "NOT_CONTAINS_TOKEN"\), and "value" |
| `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) |
| `query` | string | No | Search query string to match against ticket subject and other text fields |
| `properties` | array | No | Array of HubSpot property names to return \(e.g., \["subject", "content", "hs_ticket_priority"\]\) |
| `limit` | number | No | Maximum number of results to return \(max 200\) |
| `after` | string | No | Pagination cursor for next page \(from previous response\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tickets` | array | Array of HubSpot ticket records |
| ↳ `subject` | string | Ticket subject/name |
| ↳ `content` | string | Ticket content/description |
| ↳ `hs_pipeline` | string | Pipeline the ticket is in |
| ↳ `hs_pipeline_stage` | string | Current pipeline stage |
| ↳ `hs_ticket_priority` | string | Ticket priority \(LOW, MEDIUM, HIGH\) |
| ↳ `hs_ticket_category` | string | Ticket category |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Ticket creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records are available |
| `total` | number | Total number of matching tickets |
| `success` | boolean | Operation success status |
### `hubspot_list_line_items`
Retrieve all line items from HubSpot account with pagination support
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "name,quantity,price,amount"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "deals,quotes"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `lineItems` | array | Array of HubSpot line item records |
| ↳ `name` | string | Line item name |
| ↳ `description` | string | Full description of the product |
| ↳ `hs_sku` | string | Unique product identifier \(SKU\) |
| ↳ `quantity` | string | Number of units included |
| ↳ `price` | string | Unit price |
| ↳ `amount` | string | Total cost \(quantity * unit price\) |
| ↳ `hs_line_item_currency_code` | string | Currency code |
| ↳ `recurringbillingfrequency` | string | Recurring billing frequency |
| ↳ `hs_recurring_billing_start_date` | string | Recurring billing start date |
| ↳ `hs_recurring_billing_end_date` | string | Recurring billing end date |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records are available |
| `success` | boolean | Operation success status |
### `hubspot_get_line_item`
Retrieve a single line item by ID from HubSpot
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `lineItemId` | string | Yes | The HubSpot line item ID to retrieve |
| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "name,quantity,price,amount"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "deals,quotes"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `lineItem` | object | HubSpot line item record |
| ↳ `name` | string | Line item name |
| ↳ `description` | string | Full description of the product |
| ↳ `hs_sku` | string | Unique product identifier \(SKU\) |
| ↳ `quantity` | string | Number of units included |
| ↳ `price` | string | Unit price |
| ↳ `amount` | string | Total cost \(quantity * unit price\) |
| ↳ `hs_line_item_currency_code` | string | Currency code |
| ↳ `recurringbillingfrequency` | string | Recurring billing frequency |
| ↳ `hs_recurring_billing_start_date` | string | Recurring billing start date |
| ↳ `hs_recurring_billing_end_date` | string | Recurring billing end date |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `lineItemId` | string | The retrieved line item ID |
| `success` | boolean | Operation success status |
### `hubspot_create_line_item`
Create a new line item in HubSpot. Requires at least a name property
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `properties` | object | Yes | Line item properties as JSON object \(e.g., \{"name": "Product A", "quantity": "2", "price": "50.00", "hs_sku": "SKU-001"\}\) |
| `associations` | array | No | Array of associations to create with the line item as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `lineItem` | object | HubSpot line item record |
| ↳ `name` | string | Line item name |
| ↳ `description` | string | Full description of the product |
| ↳ `hs_sku` | string | Unique product identifier \(SKU\) |
| ↳ `quantity` | string | Number of units included |
| ↳ `price` | string | Unit price |
| ↳ `amount` | string | Total cost \(quantity * unit price\) |
| ↳ `hs_line_item_currency_code` | string | Currency code |
| ↳ `recurringbillingfrequency` | string | Recurring billing frequency |
| ↳ `hs_recurring_billing_start_date` | string | Recurring billing start date |
| ↳ `hs_recurring_billing_end_date` | string | Recurring billing end date |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `lineItemId` | string | The created line item ID |
| `success` | boolean | Operation success status |
### `hubspot_update_line_item`
Update an existing line item in HubSpot by ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `lineItemId` | string | Yes | The HubSpot line item ID to update |
| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
| `properties` | object | Yes | Line item properties to update as JSON object \(e.g., \{"quantity": "5", "price": "25.00"\}\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `lineItem` | object | HubSpot line item record |
| ↳ `name` | string | Line item name |
| ↳ `description` | string | Full description of the product |
| ↳ `hs_sku` | string | Unique product identifier \(SKU\) |
| ↳ `quantity` | string | Number of units included |
| ↳ `price` | string | Unit price |
| ↳ `amount` | string | Total cost \(quantity * unit price\) |
| ↳ `hs_line_item_currency_code` | string | Currency code |
| ↳ `recurringbillingfrequency` | string | Recurring billing frequency |
| ↳ `hs_recurring_billing_start_date` | string | Recurring billing start date |
| ↳ `hs_recurring_billing_end_date` | string | Recurring billing end date |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `lineItemId` | string | The updated line item ID |
| `success` | boolean | Operation success status |
### `hubspot_list_quotes`
Retrieve all quotes from HubSpot account with pagination support
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "hs_title,hs_expiration_date,hs_status"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "deals,line_items"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `quotes` | array | Array of HubSpot quote records |
| ↳ `hs_title` | string | Quote name/title |
| ↳ `hs_expiration_date` | string | Expiration date |
| ↳ `hs_status` | string | Quote status |
| ↳ `hs_esign_enabled` | string | Whether e-signatures are enabled |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records are available |
| `success` | boolean | Operation success status |
### `hubspot_get_quote`
Retrieve a single quote by ID from HubSpot
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `quoteId` | string | Yes | The HubSpot quote ID to retrieve |
| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "hs_title,hs_expiration_date,hs_status"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "deals,line_items"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `quote` | object | HubSpot quote record |
| ↳ `hs_title` | string | Quote name/title |
| ↳ `hs_expiration_date` | string | Expiration date |
| ↳ `hs_status` | string | Quote status |
| ↳ `hs_esign_enabled` | string | Whether e-signatures are enabled |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `quoteId` | string | The retrieved quote ID |
| `success` | boolean | Operation success status |
### `hubspot_list_appointments`
Retrieve all appointments from HubSpot account with pagination support
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "hs_meeting_title,hs_meeting_start_time"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `appointments` | array | Array of HubSpot appointment records |
| ↳ `hs_appointment_type` | string | Appointment type |
| ↳ `hs_meeting_title` | string | Meeting title |
| ↳ `hs_meeting_start_time` | string | Start time \(ISO 8601\) |
| ↳ `hs_meeting_end_time` | string | End time \(ISO 8601\) |
| ↳ `hs_meeting_location` | string | Meeting location |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `hs_createdate` | string | Creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records are available |
| `success` | boolean | Operation success status |
### `hubspot_get_appointment`
Retrieve a single appointment by ID from HubSpot
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `appointmentId` | string | Yes | The HubSpot appointment ID to retrieve |
| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "hs_meeting_title,hs_meeting_start_time"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `appointment` | object | HubSpot appointment record |
| ↳ `hs_appointment_type` | string | Appointment type |
| ↳ `hs_meeting_title` | string | Meeting title |
| ↳ `hs_meeting_start_time` | string | Start time \(ISO 8601\) |
| ↳ `hs_meeting_end_time` | string | End time \(ISO 8601\) |
| ↳ `hs_meeting_location` | string | Meeting location |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `hs_createdate` | string | Creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `appointmentId` | string | The retrieved appointment ID |
| `success` | boolean | Operation success status |
### `hubspot_create_appointment`
Create a new appointment in HubSpot
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `properties` | object | Yes | Appointment properties as JSON object \(e.g., \{"hs_meeting_title": "Discovery Call", "hs_meeting_start_time": "2024-01-15T10:00:00Z", "hs_meeting_end_time": "2024-01-15T11:00:00Z"\}\) |
| `associations` | array | No | Array of associations to create with the appointment as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `appointment` | object | HubSpot appointment record |
| ↳ `hs_appointment_type` | string | Appointment type |
| ↳ `hs_meeting_title` | string | Meeting title |
| ↳ `hs_meeting_start_time` | string | Start time \(ISO 8601\) |
| ↳ `hs_meeting_end_time` | string | End time \(ISO 8601\) |
| ↳ `hs_meeting_location` | string | Meeting location |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `hs_createdate` | string | Creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `appointmentId` | string | The created appointment ID |
| `success` | boolean | Operation success status |
### `hubspot_update_appointment`
Update an existing appointment in HubSpot by ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `appointmentId` | string | Yes | The HubSpot appointment ID to update |
| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
| `properties` | object | Yes | Appointment properties to update as JSON object \(e.g., \{"hs_meeting_title": "Updated Call", "hs_meeting_location": "Zoom"\}\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `appointment` | object | HubSpot appointment record |
| ↳ `hs_appointment_type` | string | Appointment type |
| ↳ `hs_meeting_title` | string | Meeting title |
| ↳ `hs_meeting_start_time` | string | Start time \(ISO 8601\) |
| ↳ `hs_meeting_end_time` | string | End time \(ISO 8601\) |
| ↳ `hs_meeting_location` | string | Meeting location |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `hs_createdate` | string | Creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| `appointmentId` | string | The updated appointment ID |
| `success` | boolean | Operation success status |
### `hubspot_list_carts`
Retrieve all carts from HubSpot account with pagination support
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
| `properties` | string | No | Comma-separated list of HubSpot property names to return |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `carts` | array | Array of HubSpot CRM records |
| ↳ `id` | string | Unique record ID \(hs_object_id\) |
| ↳ `createdAt` | string | Record creation timestamp \(ISO 8601\) |
| ↳ `updatedAt` | string | Record last updated timestamp \(ISO 8601\) |
| ↳ `archived` | boolean | Whether the record is archived |
| ↳ `properties` | object | Record properties |
| ↳ `associations` | object | Associated records |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records are available |
| `success` | boolean | Operation success status |
### `hubspot_get_cart`
Retrieve a single cart by ID from HubSpot
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `cartId` | string | Yes | The HubSpot cart ID to retrieve |
| `properties` | string | No | Comma-separated list of HubSpot property names to return |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `cart` | object | HubSpot CRM record |
| ↳ `id` | string | Unique record ID \(hs_object_id\) |
| ↳ `createdAt` | string | Record creation timestamp \(ISO 8601\) |
| ↳ `updatedAt` | string | Record last updated timestamp \(ISO 8601\) |
| ↳ `archived` | boolean | Whether the record is archived |
| ↳ `properties` | object | Record properties |
| ↳ `associations` | object | Associated records |
| `cartId` | string | The retrieved cart ID |
| `success` | boolean | Operation success status |
### `hubspot_list_owners`
Retrieve all owners from HubSpot account with pagination support
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | string | No | Maximum number of results per page \(max 100, default 100\) |
| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
| `email` | string | No | Filter owners by email address |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "dealname,amount,dealstage"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `owners` | array | Array of HubSpot owner objects |
| ↳ `id` | string | Owner ID |
| ↳ `email` | string | Owner email address |
| ↳ `firstName` | string | Owner first name |
| ↳ `lastName` | string | Owner last name |
| ↳ `userId` | number | Associated user ID |
| ↳ `teams` | array | Teams the owner belongs to |
| ↳ `id` | string | Team ID |
| ↳ `name` | string | Team name |
| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
| ↳ `archived` | boolean | Whether the owner is archived |
| `deals` | array | Array of HubSpot deal records |
| ↳ `dealname` | string | Deal name |
| ↳ `amount` | string | Deal amount |
| ↳ `dealstage` | string | Current deal stage |
| ↳ `pipeline` | string | Pipeline the deal is in |
| ↳ `closedate` | string | Expected close date \(ISO 8601\) |
| ↳ `dealtype` | string | Deal type \(New Business, Existing Business, etc.\) |
| ↳ `description` | string | Deal description |
| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
| ↳ `createdate` | string | Deal creation date \(ISO 8601\) |
| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
| ↳ `num_associated_contacts` | string | Number of associated contacts |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
@@ -1250,170 +529,4 @@ Retrieve all owners from HubSpot account with pagination support
| ↳ `hasMore` | boolean | Whether more records are available |
| `success` | boolean | Operation success status |
### `hubspot_list_marketing_events`
Retrieve all marketing events from HubSpot account with pagination support
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `events` | array | Array of HubSpot marketing event objects |
| ↳ `objectId` | string | Unique event ID \(HubSpot internal\) |
| ↳ `eventName` | string | Event name |
| ↳ `eventType` | string | Event type |
| ↳ `eventStatus` | string | Event status |
| ↳ `eventDescription` | string | Event description |
| ↳ `eventUrl` | string | Event URL |
| ↳ `eventOrganizer` | string | Event organizer |
| ↳ `startDateTime` | string | Start date/time \(ISO 8601\) |
| ↳ `endDateTime` | string | End date/time \(ISO 8601\) |
| ↳ `eventCancelled` | boolean | Whether event is cancelled |
| ↳ `eventCompleted` | boolean | Whether event is completed |
| ↳ `registrants` | number | Number of registrants |
| ↳ `attendees` | number | Number of attendees |
| ↳ `cancellations` | number | Number of cancellations |
| ↳ `noShows` | number | Number of no-shows |
| ↳ `externalEventId` | string | External event ID |
| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records are available |
| `success` | boolean | Operation success status |
### `hubspot_get_marketing_event`
Retrieve a single marketing event by ID from HubSpot
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `eventId` | string | Yes | The HubSpot marketing event objectId to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `event` | object | HubSpot marketing event |
| ↳ `objectId` | string | Unique event ID \(HubSpot internal\) |
| ↳ `eventName` | string | Event name |
| ↳ `eventType` | string | Event type |
| ↳ `eventStatus` | string | Event status |
| ↳ `eventDescription` | string | Event description |
| ↳ `eventUrl` | string | Event URL |
| ↳ `eventOrganizer` | string | Event organizer |
| ↳ `startDateTime` | string | Start date/time \(ISO 8601\) |
| ↳ `endDateTime` | string | End date/time \(ISO 8601\) |
| ↳ `eventCancelled` | boolean | Whether event is cancelled |
| ↳ `eventCompleted` | boolean | Whether event is completed |
| ↳ `registrants` | number | Number of registrants |
| ↳ `attendees` | number | Number of attendees |
| ↳ `cancellations` | number | Number of cancellations |
| ↳ `noShows` | number | Number of no-shows |
| ↳ `externalEventId` | string | External event ID |
| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
| `eventId` | string | The retrieved marketing event ID |
| `success` | boolean | Operation success status |
### `hubspot_list_lists`
Search and retrieve lists from HubSpot account
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `query` | string | No | Search query to filter lists by name. Leave empty to return all lists. |
| `count` | string | No | Maximum number of results to return \(default 20, max 500\) |
| `offset` | string | No | Pagination offset for next page of results \(use the offset value from previous response\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `lists` | array | Array of HubSpot list objects |
| ↳ `listId` | string | List ID |
| ↳ `name` | string | List name |
| ↳ `objectTypeId` | string | Object type ID \(e.g., 0-1 for contacts\) |
| ↳ `processingType` | string | Processing type \(MANUAL, DYNAMIC, SNAPSHOT\) |
| ↳ `processingStatus` | string | Processing status \(COMPLETE, PROCESSING\) |
| ↳ `listVersion` | number | List version number |
| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
| `paging` | object | Pagination information for fetching more results |
| ↳ `after` | string | Cursor for next page of results |
| ↳ `link` | string | Link to next page |
| `metadata` | object | Response metadata |
| ↳ `totalReturned` | number | Number of records returned in this response |
| ↳ `hasMore` | boolean | Whether more records are available |
| ↳ `total` | number | Total number of lists matching the query |
| `success` | boolean | Operation success status |
### `hubspot_get_list`
Retrieve a single list by ID from HubSpot
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `listId` | string | Yes | The HubSpot list ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `list` | object | HubSpot list |
| ↳ `listId` | string | List ID |
| ↳ `name` | string | List name |
| ↳ `objectTypeId` | string | Object type ID \(e.g., 0-1 for contacts\) |
| ↳ `processingType` | string | Processing type \(MANUAL, DYNAMIC, SNAPSHOT\) |
| ↳ `processingStatus` | string | Processing status \(COMPLETE, PROCESSING\) |
| ↳ `listVersion` | number | List version number |
| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
| `listId` | string | The retrieved list ID |
| `success` | boolean | Operation success status |
### `hubspot_create_list`
Create a new list in HubSpot. Specify the object type and processing type (MANUAL or DYNAMIC)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `name` | string | Yes | Name of the list |
| `objectTypeId` | string | Yes | Object type ID \(e.g., "0-1" for contacts, "0-2" for companies\) |
| `processingType` | string | Yes | Processing type: "MANUAL" for static lists or "DYNAMIC" for active lists |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `list` | object | HubSpot list |
| ↳ `listId` | string | List ID |
| ↳ `name` | string | List name |
| ↳ `objectTypeId` | string | Object type ID \(e.g., 0-1 for contacts\) |
| ↳ `processingType` | string | Processing type \(MANUAL, DYNAMIC, SNAPSHOT\) |
| ↳ `processingStatus` | string | Processing status \(COMPLETE, PROCESSING\) |
| ↳ `listVersion` | number | List version number |
| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
| `listId` | string | The created list ID |
| `success` | boolean | Operation success status |

View File

@@ -128,7 +128,6 @@
"reducto",
"resend",
"revenuecat",
"rippling",
"s3",
"salesforce",
"search",

View File

@@ -59,9 +59,8 @@ Generate SVG images from text prompts using QuiverAI
| --------- | ---- | ----------- |
| `success` | boolean | Whether the SVG generation succeeded |
| `output` | object | Generated SVG output |
| ↳ `file` | file | First generated SVG file |
| ↳ `files` | json | All generated SVG files \(when n &gt; 1\) |
| ↳ `svgContent` | string | Raw SVG markup content of the first result |
| ↳ `file` | file | Generated SVG file |
| ↳ `svgContent` | string | Raw SVG markup content |
| ↳ `id` | string | Generation request ID |
| ↳ `usage` | json | Token usage statistics |
| ↳ `totalTokens` | number | Total tokens used |

View File

@@ -1,506 +0,0 @@
---
title: Rippling
description: Manage employees, leave, departments, and company data in Rippling
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="rippling"
color="#FFCC1C"
/>
{/* MANUAL-CONTENT-START:intro */}
[Rippling](https://www.rippling.com/) is a unified workforce management platform that brings together HR, IT, and Finance into a single system. Rippling lets companies manage payroll, benefits, devices, apps, and more — all from one place — while automating the tedious manual work that typically bogs down HR teams. Its robust API provides programmatic access to employee data, organizational structure, leave management, and onboarding workflows.
**Why Rippling?**
- **Unified Employee System of Record:** A single source of truth for employee profiles, departments, teams, levels, and work locations — no more syncing data across disconnected tools.
- **Leave Management:** Full visibility into leave requests, balances, and types with the ability to approve or decline requests programmatically.
- **Company Insights:** Access company activity events, custom fields, and organizational hierarchy to power reporting and compliance workflows.
- **Onboarding Automation:** Push candidates directly into Rippling's onboarding flow, eliminating manual data entry when bringing on new hires.
- **Group Management:** Create and update groups for third-party app provisioning via SCIM-compatible endpoints.
**Using Rippling in Sim**
Sim's Rippling integration connects your agentic workflows directly to your Rippling account using an API key. With 19 operations spanning employees, departments, teams, leave, groups, and candidates, you can build powerful HR automations without writing backend code.
**Key benefits of using Rippling in Sim:**
- **Employee directory automation:** List, search, and retrieve employee details — including terminated employees — to power onboarding checklists, offboarding workflows, and org chart updates.
- **Leave workflow automation:** Monitor leave requests, check balances, and programmatically approve or decline requests based on custom business rules.
- **Organizational intelligence:** Query departments, teams, levels, work locations, and custom fields to build dynamic org reports or trigger actions based on structural changes.
- **Candidate onboarding:** Push candidates from your ATS or recruiting pipeline directly into Rippling's onboarding flow, complete with job title, department, and start date.
- **Activity monitoring:** Track company activity events to build audit trails, compliance dashboards, or alert workflows when key changes occur.
Whether you're automating new hire onboarding, building leave approval workflows, or syncing employee data across your tool stack, Rippling in Sim gives you direct, secure access to your HR platform — no middleware required. Simply configure your API key, select the operation you need, and let Sim handle the rest.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Rippling into your workflow. Manage employees, departments, teams, leave requests, work locations, groups, candidates, and company information.
## Tools
### `rippling_list_employees`
List all employees in Rippling with optional pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `limit` | number | No | Maximum number of employees to return \(default 100, max 100\) |
| `offset` | number | No | Offset for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `employees` | array | List of employees |
| ↳ `id` | string | Employee ID |
| ↳ `firstName` | string | First name |
| ↳ `lastName` | string | Last name |
| ↳ `workEmail` | string | Work email address |
| ↳ `personalEmail` | string | Personal email address |
| ↳ `roleState` | string | Employment status |
| ↳ `department` | string | Department name or ID |
| ↳ `title` | string | Job title |
| ↳ `startDate` | string | Employment start date |
| ↳ `endDate` | string | Employment end date |
| ↳ `manager` | string | Manager ID or name |
| ↳ `phone` | string | Phone number |
| `totalCount` | number | Number of employees returned on this page |
### `rippling_get_employee`
Get details for a specific employee by ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `employeeId` | string | Yes | The ID of the employee to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Employee ID |
| `firstName` | string | First name |
| `lastName` | string | Last name |
| `workEmail` | string | Work email address |
| `personalEmail` | string | Personal email address |
| `roleState` | string | Employment status |
| `department` | string | Department name or ID |
| `title` | string | Job title |
| `startDate` | string | Employment start date |
| `endDate` | string | Employment end date |
| `manager` | string | Manager ID or name |
| `phone` | string | Phone number |
### `rippling_list_employees_with_terminated`
List all employees in Rippling including terminated employees with optional pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `limit` | number | No | Maximum number of employees to return \(default 100, max 100\) |
| `offset` | number | No | Offset for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `employees` | array | List of employees including terminated |
| ↳ `id` | string | Employee ID |
| ↳ `firstName` | string | First name |
| ↳ `lastName` | string | Last name |
| ↳ `workEmail` | string | Work email address |
| ↳ `personalEmail` | string | Personal email address |
| ↳ `roleState` | string | Employment status |
| ↳ `department` | string | Department name or ID |
| ↳ `title` | string | Job title |
| ↳ `startDate` | string | Employment start date |
| ↳ `endDate` | string | Employment end date |
| ↳ `manager` | string | Manager ID or name |
| ↳ `phone` | string | Phone number |
| `totalCount` | number | Number of employees returned on this page |
### `rippling_list_departments`
List all departments in the Rippling organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `limit` | number | No | Maximum number of departments to return |
| `offset` | number | No | Offset for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `departments` | array | List of departments |
| ↳ `id` | string | Department ID |
| ↳ `name` | string | Department name |
| ↳ `parent` | string | Parent department ID |
| `totalCount` | number | Number of departments returned on this page |
### `rippling_list_teams`
List all teams in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `limit` | number | No | Maximum number of teams to return |
| `offset` | number | No | Offset for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `teams` | array | List of teams |
| ↳ `id` | string | Team ID |
| ↳ `name` | string | Team name |
| ↳ `parent` | string | Parent team ID |
| `totalCount` | number | Number of teams returned on this page |
### `rippling_list_levels`
List all position levels in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `limit` | number | No | Maximum number of levels to return |
| `offset` | number | No | Offset for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `levels` | array | List of position levels |
| ↳ `id` | string | Level ID |
| ↳ `name` | string | Level name |
| ↳ `parent` | string | Parent level ID |
| `totalCount` | number | Number of levels returned on this page |
### `rippling_list_work_locations`
List all work locations in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `limit` | number | No | Maximum number of work locations to return |
| `offset` | number | No | Offset for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `workLocations` | array | List of work locations |
| ↳ `id` | string | Work location ID |
| ↳ `nickname` | string | Location nickname |
| ↳ `street` | string | Street address |
| ↳ `city` | string | City |
| ↳ `state` | string | State or province |
| ↳ `zip` | string | ZIP or postal code |
| ↳ `country` | string | Country |
| `totalCount` | number | Number of work locations returned on this page |
### `rippling_get_company`
Get details for the current company in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Company ID |
| `name` | string | Company name |
| `address` | json | Company address with street, city, state, zip, country |
| `email` | string | Company email address |
| `phone` | string | Company phone number |
| `workLocations` | array | List of work location IDs |
### `rippling_get_company_activity`
Get activity events for the current company in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `startDate` | string | No | Start date filter in ISO format \(e.g. 2024-01-01\) |
| `endDate` | string | No | End date filter in ISO format \(e.g. 2024-12-31\) |
| `limit` | number | No | Maximum number of activity events to return |
| `next` | string | No | Cursor for fetching the next page of results |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `events` | array | List of company activity events |
| ↳ `id` | string | Event ID |
| ↳ `type` | string | Event type |
| ↳ `description` | string | Event description |
| ↳ `createdAt` | string | Event creation timestamp |
| ↳ `actor` | json | Actor who triggered the event \(id, name\) |
| `totalCount` | number | Number of activity events returned on this page |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `rippling_list_custom_fields`
List all custom fields defined in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `limit` | number | No | Maximum number of custom fields to return |
| `offset` | number | No | Offset for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `customFields` | array | List of custom fields |
| ↳ `id` | string | Custom field ID |
| ↳ `type` | string | Field type |
| ↳ `title` | string | Field title |
| ↳ `mandatory` | boolean | Whether the field is mandatory |
| `totalCount` | number | Number of custom fields returned on this page |
### `rippling_get_current_user`
Get the current authenticated user details
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | User ID |
| `workEmail` | string | Work email address |
| `company` | string | Company ID |
### `rippling_list_leave_requests`
List leave requests in Rippling with optional filtering by date range and status
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `startDate` | string | No | Filter by start date \(ISO date string\) |
| `endDate` | string | No | Filter by end date \(ISO date string\) |
| `status` | string | No | Filter by status \(e.g. pending, approved, declined\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `leaveRequests` | array | List of leave requests |
| ↳ `id` | string | Leave request ID |
| ↳ `requestedBy` | string | Employee ID who requested leave |
| ↳ `status` | string | Request status \(pending/approved/declined\) |
| ↳ `startDate` | string | Leave start date |
| ↳ `endDate` | string | Leave end date |
| ↳ `reason` | string | Reason for leave |
| ↳ `leaveType` | string | Type of leave |
| ↳ `createdAt` | string | When the request was created |
| `totalCount` | number | Total number of leave requests returned |
### `rippling_process_leave_request`
Approve or decline a leave request in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `leaveRequestId` | string | Yes | The ID of the leave request to process |
| `action` | string | Yes | Action to take on the leave request \(approve or decline\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Leave request ID |
| `status` | string | Updated status of the leave request |
| `requestedBy` | string | Employee ID who requested leave |
| `startDate` | string | Leave start date |
| `endDate` | string | Leave end date |
### `rippling_list_leave_balances`
List leave balances for all employees in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `limit` | number | No | Maximum number of leave balances to return |
| `offset` | number | No | Offset for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `leaveBalances` | array | List of employee leave balances |
| ↳ `employeeId` | string | Employee ID |
| ↳ `balances` | array | Leave balance entries |
| ↳ `leaveType` | string | Type of leave |
| ↳ `minutesRemaining` | number | Minutes of leave remaining |
| `totalCount` | number | Number of leave balances returned on this page |
### `rippling_get_leave_balance`
Get leave balance for a specific employee by role ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `roleId` | string | Yes | The employee/role ID to retrieve leave balance for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `employeeId` | string | Employee ID |
| `balances` | array | Leave balance entries |
| ↳ `leaveType` | string | Type of leave |
| ↳ `minutesRemaining` | number | Minutes of leave remaining |
### `rippling_list_leave_types`
List company leave types configured in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `managedBy` | string | No | Filter leave types by manager |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `leaveTypes` | array | List of company leave types |
| ↳ `id` | string | Leave type ID |
| ↳ `name` | string | Leave type name |
| ↳ `managedBy` | string | Manager of this leave type |
| `totalCount` | number | Total number of leave types returned |
### `rippling_create_group`
Create a new group in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `name` | string | Yes | Name of the group |
| `spokeId` | string | Yes | Third-party app identifier |
| `users` | json | No | Array of user ID strings to add to the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Group ID |
| `name` | string | Group name |
| `spokeId` | string | Third-party app identifier |
| `users` | array | Array of user IDs in the group |
| `version` | number | Group version number |
### `rippling_update_group`
Update an existing group in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `groupId` | string | Yes | The ID of the group to update |
| `name` | string | No | New name for the group |
| `spokeId` | string | No | Third-party app identifier |
| `users` | json | No | Array of user ID strings to set for the group |
| `version` | number | No | Group version number for optimistic concurrency |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Group ID |
| `name` | string | Group name |
| `spokeId` | string | Third-party app identifier |
| `users` | array | Array of user IDs in the group |
| `version` | number | Group version number |
### `rippling_push_candidate`
Push a candidate to onboarding in Rippling
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rippling API key |
| `firstName` | string | Yes | Candidate first name |
| `lastName` | string | Yes | Candidate last name |
| `email` | string | Yes | Candidate email address |
| `phone` | string | No | Candidate phone number |
| `jobTitle` | string | No | Job title for the candidate |
| `department` | string | No | Department for the candidate |
| `startDate` | string | No | Start date in ISO 8601 format \(e.g., 2025-01-15\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Candidate ID |
| `firstName` | string | Candidate first name |
| `lastName` | string | Candidate last name |
| `email` | string | Candidate email address |
| `status` | string | Candidate onboarding status |

View File

@@ -1,33 +0,0 @@
# Sim App Scope
These rules apply to files under `apps/sim/` in addition to the repository root [AGENTS.md](/AGENTS.md).
## Architecture
- Follow the app structure already established under `app/`, `blocks/`, `components/`, `executor/`, `hooks/`, `lib/`, `providers/`, `stores/`, `tools/`, and `triggers/`.
- Keep single responsibility for components, hooks, and stores.
- Prefer composition over large mixed-responsibility modules.
- Use `lib/` for app-wide helpers, feature-local `utils/` only when 2+ files share the helper, and inline single-use helpers.
## Imports And Types
- Always use absolute imports from `@/...`; do not add relative imports.
- Use barrel exports only when a folder has 3+ exports; do not re-export through non-barrel files.
- Use `import type` for type-only imports.
- Do not use `any`; prefer precise types or `unknown` with guards.
## Components And Styling
- Use `'use client'` only when hooks or browser-only APIs are required.
- Define a props interface for every component.
- Extract constants with `as const` where appropriate.
- Use Tailwind classes and `cn()` for conditional classes; avoid inline styles unless CSS variables are the intended mechanism.
- Keep styling local to the component; do not modify global styles for feature work.
## Testing
- Use Vitest.
- Prefer `@vitest-environment node` unless DOM APIs are required.
- Use `vi.hoisted()` + `vi.mock()` + static imports; do not use `vi.resetModules()` + `vi.doMock()` + dynamic imports except for true module-scope singletons.
- Do not use `vi.importActual()`.
- Prefer mocks and factories from `@sim/testing`.

View File

@@ -1,6 +1,7 @@
'use client'
import { useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
@@ -87,6 +88,8 @@ export default function LoginPage({
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const buttonClass = useBrandedButtonClass()
const callbackUrlParam = searchParams?.get('callbackUrl')
@@ -166,6 +169,20 @@ export default function LoginPage({
const safeCallbackUrl = callbackUrl
let errorHandled = false
// Execute Turnstile challenge on submit and get a fresh token
let token: string | undefined
if (turnstileSiteKey && turnstileRef.current) {
try {
turnstileRef.current.reset()
turnstileRef.current.execute()
token = await turnstileRef.current.getResponsePromise(15_000)
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
}
}
setFormError(null)
const result = await client.signIn.email(
{
@@ -174,6 +191,11 @@ export default function LoginPage({
callbackURL: safeCallbackUrl,
},
{
fetchOptions: {
headers: {
...(token ? { 'x-captcha-response': token } : {}),
},
},
onError: (ctx) => {
logger.error('Login error:', ctx.error)
@@ -442,6 +464,16 @@ export default function LoginPage({
</div>
</div>
{turnstileSiteKey && (
<div className='absolute'>
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ size: 'invisible', execution: 'execute' }}
/>
</div>
)}
{resetSuccessMessage && (
<div className='text-[#4CAF50] text-xs'>
<p>{resetSuccessMessage}</p>

View File

@@ -93,8 +93,6 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const buttonClass = useBrandedButtonClass()
@@ -251,30 +249,17 @@ function SignupFormContent({
const sanitizedName = trimmedName
// Execute Turnstile challenge on submit and get a fresh token
let token: string | undefined
const widget = turnstileRef.current
if (turnstileSiteKey && widget) {
let timeoutId: ReturnType<typeof setTimeout> | undefined
if (turnstileSiteKey && turnstileRef.current) {
try {
widget.reset()
token = await Promise.race([
new Promise<string>((resolve, reject) => {
captchaResolveRef.current = resolve
captchaRejectRef.current = reject
widget.execute()
}),
new Promise<string>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
}),
])
turnstileRef.current.reset()
turnstileRef.current.execute()
token = await turnstileRef.current.getResponsePromise(15_000)
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
} finally {
clearTimeout(timeoutId)
captchaResolveRef.current = null
captchaRejectRef.current = null
}
}
@@ -493,14 +478,13 @@ function SignupFormContent({
</div>
{turnstileSiteKey && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={(token) => captchaResolveRef.current?.(token)}
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
options={{ execution: 'execute' }}
/>
<div className='absolute'>
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ size: 'invisible', execution: 'execute' }}
/>
</div>
)}
{formError && (

View File

@@ -1,84 +0,0 @@
import { z } from 'zod'
import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
export const DEMO_REQUEST_REGION_VALUES = [
'north_america',
'europe',
'asia_pacific',
'latin_america',
'middle_east_africa',
'other',
] as const
export const DEMO_REQUEST_USER_COUNT_VALUES = [
'1_10',
'11_50',
'51_200',
'201_500',
'501_1000',
'1000_plus',
] as const
export const DEMO_REQUEST_REGION_OPTIONS = [
{ value: 'north_america', label: 'North America' },
{ value: 'europe', label: 'Europe' },
{ value: 'asia_pacific', label: 'Asia Pacific' },
{ value: 'latin_america', label: 'Latin America' },
{ value: 'middle_east_africa', label: 'Middle East & Africa' },
{ value: 'other', label: 'Other' },
] as const
export const DEMO_REQUEST_USER_COUNT_OPTIONS = [
{ value: '1_10', label: '1-10' },
{ value: '11_50', label: '11-50' },
{ value: '51_200', label: '51-200' },
{ value: '201_500', label: '201-500' },
{ value: '501_1000', label: '501-1,000' },
{ value: '1000_plus', label: '1,000+' },
] as const
export const demoRequestSchema = z.object({
firstName: z
.string()
.trim()
.min(1, 'First name is required')
.max(100)
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
lastName: z
.string()
.trim()
.min(1, 'Last name is required')
.max(100)
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
companyEmail: z
.string()
.trim()
.min(1, 'Company email is required')
.max(320)
.transform((value) => value.toLowerCase())
.refine((value) => quickValidateEmail(value).isValid, 'Enter a valid work email'),
phoneNumber: z
.string()
.trim()
.max(50, 'Phone number must be 50 characters or less')
.optional()
.transform((value) => (value && value.length > 0 ? value : undefined)),
region: z.enum(DEMO_REQUEST_REGION_VALUES, {
errorMap: () => ({ message: 'Please select a region' }),
}),
userCount: z.enum(DEMO_REQUEST_USER_COUNT_VALUES, {
errorMap: () => ({ message: 'Please select the number of users' }),
}),
details: z.string().trim().min(1, 'Details are required').max(2000),
})
export type DemoRequestPayload = z.infer<typeof demoRequestSchema>
export function getDemoRequestRegionLabel(value: DemoRequestPayload['region']): string {
return DEMO_REQUEST_REGION_OPTIONS.find((option) => option.value === value)?.label ?? value
}
export function getDemoRequestUserCountLabel(value: DemoRequestPayload['userCount']): string {
return DEMO_REQUEST_USER_COUNT_OPTIONS.find((option) => option.value === value)?.label ?? value
}

View File

@@ -1,298 +0,0 @@
'use client'
import { useCallback, useState } from 'react'
import {
Button,
Combobox,
FormField,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalTrigger,
Textarea,
} from '@/components/emcn'
import { Check } from '@/components/emcn/icons'
import {
DEMO_REQUEST_REGION_OPTIONS,
DEMO_REQUEST_USER_COUNT_OPTIONS,
type DemoRequestPayload,
demoRequestSchema,
} from '@/app/(home)/components/demo-request/consts'
interface DemoRequestModalProps {
children: React.ReactNode
theme?: 'dark' | 'light'
}
type DemoRequestField = keyof DemoRequestPayload
type DemoRequestErrors = Partial<Record<DemoRequestField, string>>
interface DemoRequestFormState {
firstName: string
lastName: string
companyEmail: string
phoneNumber: string
region: DemoRequestPayload['region'] | ''
userCount: DemoRequestPayload['userCount'] | ''
details: string
}
const SUBMIT_SUCCESS_MESSAGE = "We'll be in touch soon!"
const COMBOBOX_REGIONS = [...DEMO_REQUEST_REGION_OPTIONS]
const COMBOBOX_USER_COUNTS = [...DEMO_REQUEST_USER_COUNT_OPTIONS]
const INITIAL_FORM_STATE: DemoRequestFormState = {
firstName: '',
lastName: '',
companyEmail: '',
phoneNumber: '',
region: '',
userCount: '',
details: '',
}
export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalProps) {
const [open, setOpen] = useState(false)
const [form, setForm] = useState<DemoRequestFormState>(INITIAL_FORM_STATE)
const [errors, setErrors] = useState<DemoRequestErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitSuccess, setSubmitSuccess] = useState(false)
const resetForm = useCallback(() => {
setForm(INITIAL_FORM_STATE)
setErrors({})
setIsSubmitting(false)
setSubmitError(null)
setSubmitSuccess(false)
}, [])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
resetForm()
},
[resetForm]
)
const updateField = useCallback(
<TField extends keyof DemoRequestFormState>(
field: TField,
value: DemoRequestFormState[TField]
) => {
setForm((prev) => ({ ...prev, [field]: value }))
setErrors((prev) => {
if (!prev[field]) {
return prev
}
const nextErrors = { ...prev }
delete nextErrors[field]
return nextErrors
})
setSubmitError(null)
setSubmitSuccess(false)
},
[]
)
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setSubmitError(null)
setSubmitSuccess(false)
const parsed = demoRequestSchema.safeParse({
...form,
phoneNumber: form.phoneNumber || undefined,
})
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors
setErrors({
firstName: fieldErrors.firstName?.[0],
lastName: fieldErrors.lastName?.[0],
companyEmail: fieldErrors.companyEmail?.[0],
phoneNumber: fieldErrors.phoneNumber?.[0],
region: fieldErrors.region?.[0],
userCount: fieldErrors.userCount?.[0],
details: fieldErrors.details?.[0],
})
return
}
setIsSubmitting(true)
try {
const response = await fetch('/api/demo-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed.data),
})
const result = (await response.json().catch(() => null)) as {
error?: string
message?: string
} | null
if (!response.ok) {
throw new Error(result?.error || 'Failed to submit demo request')
}
setSubmitSuccess(true)
} catch (error) {
setSubmitError(
error instanceof Error
? error.message
: 'Failed to submit demo request. Please try again.'
)
} finally {
setIsSubmitting(false)
}
},
[form, resetForm]
)
return (
<Modal open={open} onOpenChange={handleOpenChange}>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent size='lg' className={theme === 'dark' ? 'dark' : undefined}>
<ModalHeader>
<span className={submitSuccess ? 'sr-only' : undefined}>
{submitSuccess ? 'Demo request submitted' : 'Nearly there!'}
</span>
</ModalHeader>
<div className='relative flex-1'>
<form
onSubmit={handleSubmit}
aria-hidden={submitSuccess}
className={
submitSuccess
? 'pointer-events-none invisible flex h-full flex-col'
: 'flex h-full flex-col'
}
>
<ModalBody>
<div className='space-y-4'>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField htmlFor='firstName' label='First name' error={errors.firstName}>
<Input
id='firstName'
value={form.firstName}
onChange={(event) => updateField('firstName', event.target.value)}
placeholder='First'
/>
</FormField>
<FormField htmlFor='lastName' label='Last name' error={errors.lastName}>
<Input
id='lastName'
value={form.lastName}
onChange={(event) => updateField('lastName', event.target.value)}
placeholder='Last'
/>
</FormField>
</div>
<FormField htmlFor='companyEmail' label='Company email' error={errors.companyEmail}>
<Input
id='companyEmail'
type='email'
value={form.companyEmail}
onChange={(event) => updateField('companyEmail', event.target.value)}
placeholder='Your work email'
/>
</FormField>
<FormField
htmlFor='phoneNumber'
label='Phone number'
optional
error={errors.phoneNumber}
>
<Input
id='phoneNumber'
type='tel'
value={form.phoneNumber}
onChange={(event) => updateField('phoneNumber', event.target.value)}
placeholder='Your phone number'
/>
</FormField>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField htmlFor='region' label='Region' error={errors.region}>
<Combobox
options={COMBOBOX_REGIONS}
value={form.region}
selectedValue={form.region}
onChange={(value) =>
updateField('region', value as DemoRequestPayload['region'])
}
placeholder='Select'
editable={false}
filterOptions={false}
/>
</FormField>
<FormField htmlFor='userCount' label='Number of users' error={errors.userCount}>
<Combobox
options={COMBOBOX_USER_COUNTS}
value={form.userCount}
selectedValue={form.userCount}
onChange={(value) =>
updateField('userCount', value as DemoRequestPayload['userCount'])
}
placeholder='Select'
editable={false}
filterOptions={false}
/>
</FormField>
</div>
<FormField htmlFor='details' label='Details' error={errors.details}>
<Textarea
id='details'
value={form.details}
onChange={(event) => updateField('details', event.target.value)}
placeholder='Tell us about your needs and questions'
/>
</FormField>
</div>
</ModalBody>
<ModalFooter className='flex-col items-stretch gap-3'>
{submitError && <p className='text-[13px] text-[var(--text-error)]'>{submitError}</p>}
<Button type='submit' variant='primary' disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
</ModalFooter>
</form>
{submitSuccess ? (
<div className='absolute inset-0 flex items-center justify-center px-8 pb-10 sm:px-12 sm:pb-14'>
<div className='flex max-w-md flex-col items-center justify-center text-center'>
<div className='flex h-20 w-20 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--bg-subtle)] text-[var(--text-primary)]'>
<Check className='h-10 w-10' />
</div>
<h2 className='mt-8 font-medium text-[34px] text-[var(--text-primary)] leading-[1.1] tracking-[-0.03em]'>
{SUBMIT_SUCCESS_MESSAGE}
</h2>
<p className='mt-4 text-[17px] text-[var(--text-secondary)] leading-7'>
Our team will be in touch soon. If you have any questions, please email us at{' '}
<a
href='mailto:enterprise@sim.ai'
className='text-[var(--text-primary)] underline underline-offset-2'
>
enterprise@sim.ai
</a>
.
</p>
</div>
</div>
) : null}
</div>
</ModalContent>
</Modal>
)
}

View File

@@ -18,7 +18,6 @@ import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
import { Lock } from '@/components/emcn/icons'
import { GithubIcon } from '@/components/icons'
import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
import { AccessControlPanel } from '@/app/(home)/components/enterprise/components/access-control-panel'
import { AuditLogPreview } from '@/app/(home)/components/enterprise/components/audit-log-preview'
@@ -178,12 +177,12 @@ export default function Enterprise() {
{/* Fade edges */}
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 bottom-0 left-0 z-10 w-24'
className='pointer-events-none absolute top-0 bottom-0 left-0 z-10 w-16'
style={{ background: 'linear-gradient(to right, #1C1C1C, transparent)' }}
/>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 right-0 bottom-0 z-10 w-24'
className='pointer-events-none absolute top-0 right-0 bottom-0 z-10 w-16'
style={{ background: 'linear-gradient(to left, #1C1C1C, transparent)' }}
/>
{/* Duplicate tags for seamless loop */}
@@ -205,31 +204,31 @@ export default function Enterprise() {
<p className='font-[430] font-season text-[#F6F6F6]/40 text-[15px] leading-[150%] tracking-[0.02em]'>
Ready for growth?
</p>
<DemoRequestModal>
<button
type='button'
className='group/cta inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-white bg-white px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Book a demo
<span className='relative h-[10px] w-[10px] shrink-0'>
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
<svg
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
viewBox='0 0 10 10'
<Link
href='https://form.typeform.com/to/jqCO12pF'
target='_blank'
rel='noopener noreferrer'
className='group/cta inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-white bg-white px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Book a demo
<span className='relative h-[10px] w-[10px] shrink-0'>
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
<svg
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
viewBox='0 0 10 10'
fill='none'
>
<path
d='M1 5H8M5.5 2L8.5 5L5.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
>
<path
d='M1 5H8M5.5 2L8.5 5L5.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
</span>
</button>
</DemoRequestModal>
/>
</svg>
</span>
</Link>
</div>
</div>
</div>

View File

@@ -608,12 +608,9 @@ const MOCK_KB_DATA = [
const MD_COMPONENTS: Components = {
h1: ({ children }) => (
<p
role='presentation'
className='mb-4 border-[#E5E5E5] border-b pb-2 font-semibold text-[#1C1C1C] text-[20px]'
>
<h1 className='mb-4 border-[#E5E5E5] border-b pb-2 font-semibold text-[#1C1C1C] text-[20px]'>
{children}
</p>
</h1>
),
h2: ({ children }) => (
<h2 className='mt-5 mb-3 border-[#E5E5E5] border-b pb-1.5 font-semibold text-[#1C1C1C] text-[16px]'>

View File

@@ -3,7 +3,6 @@
import dynamic from 'next/dynamic'
import Image from 'next/image'
import Link from 'next/link'
import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
import {
BlocksLeftAnimated,
BlocksRightAnimated,
@@ -71,15 +70,15 @@ export default function Hero() {
</p>
<div className='mt-[12px] flex items-center gap-[8px]'>
<DemoRequestModal>
<button
type='button'
className={`${CTA_BASE} border-[#3d3d3d] bg-transparent text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
aria-label='Get a demo'
>
Get a demo
</button>
</DemoRequestModal>
<a
href='https://form.typeform.com/to/jqCO12pF'
target='_blank'
rel='noopener noreferrer'
className={`${CTA_BASE} border-[#3d3d3d] text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
aria-label='Get a demo'
>
Get a demo
</a>
<Link
href='/signup'
className={`${CTA_BASE} gap-[8px] border-[#FFFFFF] bg-[#FFFFFF] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}

View File

@@ -49,13 +49,12 @@ export const LandingPreviewHome = memo(function LandingPreviewHome() {
return (
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-[24px] pb-[2vh]'>
<p
role='presentation'
<h1
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
style={{ color: C.TEXT_PRIMARY }}
>
What should we get done?
</p>
</h1>
<div className='w-full max-w-[32rem]'>
<div

View File

@@ -1,4 +1,3 @@
import Image from 'next/image'
import Link from 'next/link'
import { cn } from '@/lib/core/utils/cn'
@@ -13,7 +12,6 @@ function BlogCard({
image,
title,
imageHeight,
sizes,
titleSize = '12px',
className,
}: {
@@ -21,7 +19,6 @@ function BlogCard({
image: string
title: string
imageHeight: string
sizes: string
titleSize?: string
className?: string
}) {
@@ -34,13 +31,12 @@ function BlogCard({
)}
prefetch={false}
>
<div className='relative w-full overflow-hidden bg-[#141414]' style={{ height: imageHeight }}>
<Image
<div className='w-full overflow-hidden bg-[#141414]' style={{ height: imageHeight }}>
<img
src={image}
alt={title}
fill
sizes={sizes}
className='object-cover transition-transform duration-200 group-hover/card:scale-[1.02]'
decoding='async'
className='h-full w-full object-cover transition-transform duration-200 group-hover/card:scale-[1.02]'
/>
</div>
<div className='flex-shrink-0 px-[10px] py-[6px]'>
@@ -72,7 +68,6 @@ export function BlogDropdown({ posts }: BlogDropdownProps) {
image={featured.ogImage}
title={featured.title}
imageHeight='190px'
sizes='340px'
titleSize='13px'
className='col-span-2 row-span-2'
/>
@@ -84,7 +79,6 @@ export function BlogDropdown({ posts }: BlogDropdownProps) {
image={post.ogImage}
title={post.title}
imageHeight='72px'
sizes='170px'
/>
))}
</div>

View File

@@ -1,4 +1,3 @@
import Image from 'next/image'
import { AgentIcon, GithubOutlineIcon, McpIcon } from '@/components/icons'
const PREVIEW_CARDS = [
@@ -47,13 +46,12 @@ export function DocsDropdown() {
rel='noopener noreferrer'
className='group/card overflow-hidden rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] transition-colors hover:border-[#3D3D3D] hover:bg-[#2A2A2A]'
>
<div className='relative h-[120px] w-full overflow-hidden bg-[#141414]'>
<Image
<div className='h-[120px] w-full overflow-hidden bg-[#141414]'>
<img
src={card.image}
alt={card.title}
fill
sizes='220px'
className='scale-[1.04] object-cover transition-transform duration-200 group-hover/card:scale-[1.06]'
decoding='async'
className='h-full w-full scale-[1.04] object-cover transition-transform duration-200 group-hover/card:scale-[1.06]'
/>
</div>
<div className='px-[10px] py-[8px]'>

View File

@@ -3,9 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { GithubOutlineIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import {
BlogDropdown,
@@ -42,12 +40,6 @@ interface NavbarProps {
export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps) {
const brand = getBrandConfig()
const searchParams = useSearchParams()
const { data: session, isPending: isSessionPending } = useSession()
const isAuthenticated = Boolean(session?.user?.id)
const isBrowsingHome = searchParams.has('home')
const useHomeLinks = isAuthenticated || isBrowsingHome
const logoHref = useHomeLinks ? '/?home' : '/'
const [activeDropdown, setActiveDropdown] = useState<DropdownId>(null)
const [hoveredLink, setHoveredLink] = useState<string | null>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
@@ -100,7 +92,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
itemScope
itemType='https://schema.org/SiteNavigationElement'
>
<Link href={logoHref} className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
<Link href='/' className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
<span itemProp='name' className='sr-only'>
{brand.name}
</span>
@@ -129,9 +121,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
{!logoOnly && (
<>
<ul className='mt-[0.75px] hidden lg:flex'>
{NAV_LINKS.map(({ label, href: rawHref, external, icon, dropdown }) => {
const href =
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
{NAV_LINKS.map(({ label, href, external, icon, dropdown }) => {
const hasDropdown = !!dropdown
const isActive = hasDropdown && activeDropdown === dropdown
const isThisHovered = hoveredLink === label
@@ -216,38 +206,21 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
<div className='hidden flex-1 lg:block' />
<div
className={cn(
'hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex',
isSessionPending && 'invisible'
)}
>
{isAuthenticated ? (
<Link
href='/workspace'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
aria-label='Go to app'
>
Go to App
</Link>
) : (
<>
<Link
href='/login'
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
aria-label='Get started with Sim'
>
Get started
</Link>
</>
)}
<div className='hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex'>
<Link
href='/login'
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
aria-label='Get started with Sim'
>
Get started
</Link>
</div>
<div className='flex flex-1 items-center justify-end pr-[20px] lg:hidden'>
@@ -269,34 +242,30 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
)}
>
<ul className='flex flex-col'>
{NAV_LINKS.map(({ label, href: rawHref, external }) => {
const href =
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
return (
<li key={label} className='border-[#2A2A2A] border-b'>
{external ? (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
<ExternalArrowIcon />
</a>
) : (
<Link
href={href}
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
</Link>
)}
</li>
)
})}
{NAV_LINKS.map(({ label, href, external }) => (
<li key={label} className='border-[#2A2A2A] border-b'>
{external ? (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
<ExternalArrowIcon />
</a>
) : (
<Link
href={href}
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
</Link>
)}
</li>
))}
<li className='border-[#2A2A2A] border-b'>
<a
href='https://github.com/simstudioai/sim'
@@ -311,41 +280,23 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
</li>
</ul>
<div
className={cn(
'mt-auto flex flex-col gap-[10px] p-[20px]',
isSessionPending && 'invisible'
)}
>
{isAuthenticated ? (
<Link
href='/workspace'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Go to app'
>
Go to App
</Link>
) : (
<>
<Link
href='/login'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Get started with Sim'
>
Get started
</Link>
</>
)}
<div className='mt-auto flex flex-col gap-[10px] p-[20px]'>
<Link
href='/login'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Get started with Sim'
>
Get started
</Link>
</div>
</div>
</>

View File

@@ -1,6 +1,5 @@
import Link from 'next/link'
import { Badge } from '@/components/emcn'
import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
interface PricingTier {
id: string
@@ -10,7 +9,7 @@ interface PricingTier {
billingPeriod?: string
color: string
features: string[]
cta: { label: string; href?: string; action?: 'demo-request' }
cta: { label: string; href: string }
}
const PRICING_TIERS: PricingTier[] = [
@@ -79,7 +78,7 @@ const PRICING_TIERS: PricingTier[] = [
'SSO & SCIM · SOC2 & HIPAA',
'Self hosting · Dedicated support',
],
cta: { label: 'Book a demo', action: 'demo-request' },
cta: { label: 'Book a demo', href: 'https://form.typeform.com/to/jqCO12pF' },
},
]
@@ -102,7 +101,7 @@ interface PricingCardProps {
}
function PricingCard({ tier }: PricingCardProps) {
const isDemoRequest = tier.cta.action === 'demo-request'
const isEnterprise = tier.id === 'enterprise'
const isPro = tier.id === 'pro'
return (
@@ -125,25 +124,25 @@ function PricingCard({ tier }: PricingCardProps) {
)}
</p>
<div className='mt-4'>
{isDemoRequest ? (
<DemoRequestModal theme='light'>
<button
type='button'
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] bg-transparent px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
>
{tier.cta.label}
</button>
</DemoRequestModal>
{isEnterprise ? (
<a
href={tier.cta.href}
target='_blank'
rel='noopener noreferrer'
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
>
{tier.cta.label}
</a>
) : isPro ? (
<Link
href={tier.cta.href || '/signup'}
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
>
{tier.cta.label}
</Link>
) : (
<Link
href={tier.cta.href || '/signup'}
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
>
{tier.cta.label}

View File

@@ -37,17 +37,11 @@ export default async function Landing() {
return (
<div className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C]`}>
<a
href='#main-content'
className='sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:rounded-md focus:bg-white focus:px-4 focus:py-2 focus:font-medium focus:text-black focus:text-sm'
>
Skip to main content
</a>
<StructuredData />
<header>
<Navbar blogPosts={blogPosts} />
</header>
<main id='main-content'>
<main>
<Hero />
<Templates />
<Features />

View File

@@ -5,9 +5,8 @@ import { createLogger } from '@sim/logger'
import { ArrowRight, ChevronRight } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useRouter } from 'next/navigation'
import { GithubIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
import { useBrandConfig } from '@/ee/whitelabeling'
@@ -27,12 +26,6 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
const router = useRouter()
const brand = useBrandConfig()
const buttonClass = useBrandedButtonClass()
const searchParams = useSearchParams()
const { data: session, isPending: isSessionPending } = useSession()
const isAuthenticated = Boolean(session?.user?.id)
const isBrowsingHome = searchParams.has('home')
const useHomeLinks = isAuthenticated || isBrowsingHome
const logoHref = useHomeLinks ? '/?home' : '/'
useEffect(() => {
if (variant !== 'landing') return
@@ -79,7 +72,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
</li>
<li>
<Link
href={useHomeLinks ? '/?home#pricing' : '/#pricing'}
href='/?from=nav#pricing'
className='text-[16px] text-muted-foreground transition-colors hover:text-foreground'
scroll={true}
>
@@ -131,7 +124,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
itemType='https://schema.org/SiteNavigationElement'
>
<div className='flex items-center gap-[34px]'>
<Link href={logoHref} aria-label={`${brand.name} home`} itemProp='url'>
<Link href='/?from=nav' aria-label={`${brand.name} home`} itemProp='url'>
<span itemProp='name' className='sr-only'>
{brand.name} Home
</span>
@@ -169,70 +162,45 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
{/* Auth Buttons - show only when hosted, regardless of variant */}
{!hideAuthButtons && isHosted && (
<div
className={`flex items-center justify-center gap-[16px] pt-[1.5px]${isSessionPending ? ' invisible' : ''}`}
>
{isAuthenticated ? (
<Link
href='/workspace'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
aria-label='Go to app'
>
<span className='flex items-center gap-1'>
Go to App
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
<div className='flex items-center justify-center gap-[16px] pt-[1.5px]'>
<button
onClick={handleLoginClick}
onMouseEnter={() => setIsLoginHovered(true)}
onMouseLeave={() => setIsLoginHovered(false)}
className='group hidden text-[#2E2E2E] text-[16px] transition-colors hover:text-foreground md:block'
type='button'
aria-label='Log in to your account'
>
<span className='flex items-center gap-1'>
Log in
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isLoginHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</Link>
) : (
<>
<button
onClick={handleLoginClick}
onMouseEnter={() => setIsLoginHovered(true)}
onMouseLeave={() => setIsLoginHovered(false)}
className='group hidden text-[#2E2E2E] text-[16px] transition-colors hover:text-foreground md:block'
type='button'
aria-label='Log in to your account'
>
<span className='flex items-center gap-1'>
Log in
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isLoginHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</button>
<Link
href='/signup'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
aria-label='Get started with Sim - Sign up for free'
prefetch={true}
>
<span className='flex items-center gap-1'>
Get started
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Link>
</>
)}
</span>
</button>
<Link
href='/signup'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
aria-label='Get started with Sim - Sign up for free'
prefetch={true}
>
<span className='flex items-center gap-1'>
Get started
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Link>
</div>
)}
</nav>

View File

@@ -1,8 +1,6 @@
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { TEMPLATES } from '@/app/workspace/[workspaceId]/home/components/template-prompts/consts'
import { IntegrationIcon } from '../components/integration-icon'
import { blockTypeToIconMap } from '../data/icon-mapping'
@@ -13,7 +11,6 @@ import { TemplateCardButton } from './components/template-card-button'
const allIntegrations = integrations as Integration[]
const INTEGRATION_COUNT = allIntegrations.length
const baseUrl = getBaseUrl()
/** Fast O(1) lookups — avoids repeated linear scans inside render loops. */
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
@@ -114,7 +111,7 @@ function buildFAQs(integration: Integration): FAQItem[] {
? [
{
question: `How do I trigger a Sim workflow from ${name} automatically?`,
answer: `Add a ${name} trigger block to your workflow and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly fires your workflow — no polling, no delay.`,
answer: `In your Sim workflow, switch the ${name} block to Trigger mode and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly fires your workflow — no polling, no delay.`,
},
{
question: `What data does Sim receive when a ${name} event triggers a workflow?`,
@@ -176,24 +173,16 @@ export async function generateMetadata({
openGraph: {
title: `${name} Integration — AI Workflow Automation | Sim`,
description: `Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${description.slice(0, 100).trimEnd()}.`,
url: `${baseUrl}/integrations/${slug}`,
url: `https://sim.ai/integrations/${slug}`,
type: 'website',
images: [
{
url: `${baseUrl}/opengraph-image.png`,
width: 1200,
height: 630,
alt: `${name} Integration — Sim`,
},
],
images: [{ url: 'https://sim.ai/opengraph-image.png', width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: `${name} Integration | Sim`,
description: `Automate ${name} with AI-powered workflows. Connect to ${INTEGRATION_COUNT - 1}+ tools. Free to start.`,
images: [{ url: `${baseUrl}/opengraph-image.png`, alt: `${name} Integration — Sim` }],
},
alternates: { canonical: `${baseUrl}/integrations/${slug}` },
alternates: { canonical: `https://sim.ai/integrations/${slug}` },
}
}
@@ -222,14 +211,14 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
{
'@type': 'ListItem',
position: 2,
name: 'Integrations',
item: `${baseUrl}/integrations`,
item: 'https://sim.ai/integrations',
},
{ '@type': 'ListItem', position: 3, name, item: `${baseUrl}/integrations/${slug}` },
{ '@type': 'ListItem', position: 3, name, item: `https://sim.ai/integrations/${slug}` },
],
}
@@ -238,7 +227,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
'@type': 'SoftwareApplication',
name: `${name} Integration`,
description,
url: `${baseUrl}/integrations/${slug}`,
url: `https://sim.ai/integrations/${slug}`,
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
featureList: operations.map((o) => o.name),
@@ -658,43 +647,39 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
</div>
{/* Related integrations — internal linking for SEO */}
<div className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
{relatedIntegrations.length > 0 && (
<>
<h3 className='mb-4 font-[500] text-[#ECECEC] text-[14px]'>
Related integrations
</h3>
<ul className='space-y-2'>
{relatedIntegrations.map((rel) => (
<li key={rel.slug}>
<Link
href={`/integrations/${rel.slug}`}
className='flex items-center gap-2.5 rounded-[6px] p-1.5 text-[#999] text-[13px] transition-colors hover:bg-[#2A2A2A] hover:text-[#ECECEC]'
>
<IntegrationIcon
bgColor={rel.bgColor}
name={rel.name}
Icon={blockTypeToIconMap[rel.type]}
as='span'
className='h-6 w-6 rounded-[4px]'
iconClassName='h-3.5 w-3.5'
fallbackClassName='text-[10px]'
aria-hidden='true'
/>
{rel.name}
</Link>
</li>
))}
</ul>
</>
)}
<Link
href='/integrations'
className={`block text-[#555] text-[12px] transition-colors hover:text-[#999]${relatedIntegrations.length > 0 ? ' mt-4' : ''}`}
>
All integrations
</Link>
</div>
{relatedIntegrations.length > 0 && (
<div className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
<h3 className='mb-4 font-[500] text-[#ECECEC] text-[14px]'>Related integrations</h3>
<ul className='space-y-2'>
{relatedIntegrations.map((rel) => (
<li key={rel.slug}>
<Link
href={`/integrations/${rel.slug}`}
className='flex items-center gap-2.5 rounded-[6px] p-1.5 text-[#999] text-[13px] transition-colors hover:bg-[#2A2A2A] hover:text-[#ECECEC]'
>
<IntegrationIcon
bgColor={rel.bgColor}
name={rel.name}
Icon={blockTypeToIconMap[rel.type]}
as='span'
className='h-6 w-6 rounded-[4px]'
iconClassName='h-3.5 w-3.5'
fallbackClassName='text-[10px]'
aria-hidden='true'
/>
{rel.name}
</Link>
</li>
))}
</ul>
<Link
href='/integrations'
className='mt-4 block text-[#555] text-[12px] transition-colors hover:text-[#999]'
>
All integrations
</Link>
</div>
)}
</aside>
</div>
@@ -705,12 +690,10 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
>
{/* Logo pair: Sim × Integration */}
<div className='mx-auto mb-6 flex items-center justify-center gap-3'>
<Image
<img
src='/brandbook/logo/small.png'
alt='Sim'
width={56}
height={56}
className='shrink-0 rounded-xl'
className='h-14 w-14 shrink-0 rounded-xl'
/>
<div className='flex items-center gap-2'>
<span className='h-px w-5 bg-[#3d3d3d]' aria-hidden='true' />

View File

@@ -133,7 +133,6 @@ import {
ReductoIcon,
ResendIcon,
RevenueCatIcon,
RipplingIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
@@ -307,7 +306,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
reducto_v2: ReductoIcon,
resend: ResendIcon,
revenuecat: RevenueCatIcon,
rippling: RipplingIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,

View File

@@ -168,35 +168,35 @@
"operations": [
{
"name": "List Bases",
"description": "List all bases the authenticated user has access to"
"description": ""
},
{
"name": "List Tables",
"description": "List all tables and their schema in an Airtable base"
"description": ""
},
{
"name": "Get Base Schema",
"description": "Get the schema of all tables, fields, and views in an Airtable base"
"description": ""
},
{
"name": "List Records",
"description": "Read records from an Airtable table"
"description": ""
},
{
"name": "Get Record",
"description": "Retrieve a single record from an Airtable table by its ID"
"description": ""
},
{
"name": "Create Records",
"description": "Write new records to an Airtable table"
"description": ""
},
{
"name": "Update Record",
"description": "Update an existing record in an Airtable table by ID"
"description": ""
},
{
"name": "Update Multiple Records",
"description": "Update multiple existing records in an Airtable table"
"description": ""
}
],
"operationCount": 8,
@@ -287,11 +287,11 @@
},
{
"name": "Delete Index",
"description": "Delete an entire Algolia index and all its records"
"description": ""
},
{
"name": "Copy/Move Index",
"description": "Copy or move an Algolia index to a new destination"
"description": ""
},
{
"name": "Clear Records",
@@ -609,7 +609,7 @@
},
{
"name": "Add to Sequence",
"description": "Add contacts to an Apollo sequence"
"description": ""
},
{
"name": "Create Task",
@@ -1399,7 +1399,7 @@
},
{
"name": "List Event Types",
"description": "Retrieve a list of all event types"
"description": ""
},
{
"name": "Update Event Type",
@@ -1508,7 +1508,7 @@
},
{
"name": "List Event Types",
"description": "Retrieve a list of all event types for a user or organization"
"description": ""
},
{
"name": "Get Event Type",
@@ -1758,11 +1758,11 @@
"operations": [
{
"name": "Read Page",
"description": "Retrieve content from Confluence pages using the Confluence API."
"description": ""
},
{
"name": "Create Page",
"description": "Create a new page in a Confluence space."
"description": ""
},
{
"name": "Update Page",
@@ -1770,7 +1770,7 @@
},
{
"name": "Delete Page",
"description": "Delete a Confluence page. By default moves to trash; use purge=true to permanently delete."
"description": ""
},
{
"name": "List Pages in Space",
@@ -2635,15 +2635,15 @@
},
{
"name": "Create Index",
"description": "Create a new index with optional settings and mappings."
"description": ""
},
{
"name": "Delete Index",
"description": "Delete an index and all its documents. This operation is irreversible."
"description": ""
},
{
"name": "Get Index Info",
"description": "Retrieve index information including settings, mappings, and aliases."
"description": ""
},
{
"name": "List Indices",
@@ -3668,51 +3668,51 @@
"operations": [
{
"name": "Send Email",
"description": "Send Gmail messages"
"description": ""
},
{
"name": "Read Email",
"description": "Read Gmail messages"
"description": ""
},
{
"name": "Draft Email",
"description": "Draft emails using Gmail"
"description": ""
},
{
"name": "Search Email",
"description": "Search emails in Gmail"
"description": ""
},
{
"name": "Move Email",
"description": "Move emails between Gmail labels/folders"
"description": ""
},
{
"name": "Mark as Read",
"description": "Mark a Gmail message as read"
"description": ""
},
{
"name": "Mark as Unread",
"description": "Mark a Gmail message as unread"
"description": ""
},
{
"name": "Archive Email",
"description": "Archive a Gmail message (remove from inbox)"
"description": ""
},
{
"name": "Unarchive Email",
"description": "Unarchive a Gmail message (move back to inbox)"
"description": ""
},
{
"name": "Delete Email",
"description": "Delete a Gmail message (move to trash)"
"description": ""
},
{
"name": "Add Label",
"description": "Add label(s) to a Gmail message"
"description": ""
},
{
"name": "Remove Label",
"description": "Remove label(s) from a Gmail message"
"description": ""
}
],
"operationCount": 12,
@@ -4080,7 +4080,7 @@
},
{
"name": "Create File",
"description": "Upload a file to Google Drive with complete metadata returned"
"description": ""
},
{
"name": "Upload File",
@@ -4453,31 +4453,31 @@
},
{
"name": "Clear Data",
"description": "Clear values from a specific range in a Google Sheets spreadsheet"
"description": ""
},
{
"name": "Get Spreadsheet Info",
"description": "Get metadata about a Google Sheets spreadsheet including title and sheet list"
"description": ""
},
{
"name": "Create Spreadsheet",
"description": "Create a new Google Sheets spreadsheet"
"description": ""
},
{
"name": "Batch Read",
"description": "Read multiple ranges from a Google Sheets spreadsheet in a single request"
"description": ""
},
{
"name": "Batch Update",
"description": "Update multiple ranges in a Google Sheets spreadsheet in a single request"
"description": ""
},
{
"name": "Batch Clear",
"description": "Clear multiple ranges in a Google Sheets spreadsheet in a single request"
"description": ""
},
{
"name": "Copy Sheet",
"description": "Copy a sheet from one spreadsheet to another"
"description": ""
}
],
"operationCount": 11,
@@ -4540,7 +4540,7 @@
},
{
"name": "Reorder Slides",
"description": "Move one or more slides to a new position in a Google Slides presentation"
"description": ""
},
{
"name": "Create Table",
@@ -4807,7 +4807,7 @@
},
{
"name": "List Meeting Types",
"description": "List all meeting types in the workspace"
"description": ""
},
{
"name": "Create Webhook",
@@ -5062,9 +5062,13 @@
"iconName": "HubspotIcon",
"docsUrl": "https://docs.sim.ai/tools/hubspot",
"operations": [
{
"name": "Get Users",
"description": "Retrieve all users from HubSpot account"
},
{
"name": "Get Contacts",
"description": "Retrieve all contacts from HubSpot account with pagination support"
"description": ""
},
{
"name": "Create Contact",
@@ -5080,7 +5084,7 @@
},
{
"name": "Get Companies",
"description": "Retrieve all companies from HubSpot account with pagination support"
"description": ""
},
{
"name": "Create Company",
@@ -5096,90 +5100,10 @@
},
{
"name": "Get Deals",
"description": "Retrieve all deals from HubSpot account with pagination support"
},
{
"name": "Create Deal",
"description": "Create a new deal in HubSpot. Requires at least a dealname property"
},
{
"name": "Update Deal",
"description": "Update an existing deal in HubSpot by ID"
},
{
"name": "Search Deals",
"description": "Search for deals in HubSpot using filters, sorting, and queries"
},
{
"name": "Get Tickets",
"description": "Retrieve all tickets from HubSpot account with pagination support"
},
{
"name": "Create Ticket",
"description": "Create a new ticket in HubSpot. Requires subject and hs_pipeline_stage properties"
},
{
"name": "Update Ticket",
"description": "Update an existing ticket in HubSpot by ID"
},
{
"name": "Search Tickets",
"description": "Search for tickets in HubSpot using filters, sorting, and queries"
},
{
"name": "Get Line Items",
"description": "Retrieve all line items from HubSpot account with pagination support"
},
{
"name": "Create Line Item",
"description": "Create a new line item in HubSpot. Requires at least a name property"
},
{
"name": "Update Line Item",
"description": "Update an existing line item in HubSpot by ID"
},
{
"name": "Get Quotes",
"description": "Retrieve all quotes from HubSpot account with pagination support"
},
{
"name": "Get Appointments",
"description": "Retrieve all appointments from HubSpot account with pagination support"
},
{
"name": "Create Appointment",
"description": "Create a new appointment in HubSpot"
},
{
"name": "Update Appointment",
"description": "Update an existing appointment in HubSpot by ID"
},
{
"name": "Get Carts",
"description": "Retrieve all carts from HubSpot account with pagination support"
},
{
"name": "List Owners",
"description": "Retrieve all owners from HubSpot account with pagination support"
},
{
"name": "Get Marketing Events",
"description": "Retrieve all marketing events from HubSpot account with pagination support"
},
{
"name": "Get Lists",
"description": "Search and retrieve lists from HubSpot account"
},
{
"name": "Create List",
"description": "Create a new list in HubSpot. Specify the object type and processing type (MANUAL or DYNAMIC)"
},
{
"name": "Get Users",
"description": "Retrieve all users from HubSpot account"
"description": ""
}
],
"operationCount": 29,
"operationCount": 10,
"triggers": [
{
"id": "hubspot_contact_created",
@@ -5687,7 +5611,7 @@
},
{
"name": "Update Ticket",
"description": "Update a ticket in Intercom (change state, assignment, attributes)"
"description": ""
},
{
"name": "Create Message",
@@ -5695,59 +5619,59 @@
},
{
"name": "List Admins",
"description": "Fetch a list of all admins for the workspace"
"description": ""
},
{
"name": "Close Conversation",
"description": "Close a conversation in Intercom"
"description": ""
},
{
"name": "Open Conversation",
"description": "Open a closed or snoozed conversation in Intercom"
"description": ""
},
{
"name": "Snooze Conversation",
"description": "Snooze a conversation to reopen at a future time"
"description": ""
},
{
"name": "Assign Conversation",
"description": "Assign a conversation to an admin or team in Intercom"
"description": ""
},
{
"name": "List Tags",
"description": "Fetch a list of all tags in the workspace"
"description": ""
},
{
"name": "Create Tag",
"description": "Create a new tag or update an existing tag name"
"description": ""
},
{
"name": "Tag Contact",
"description": "Add a tag to a specific contact"
"description": ""
},
{
"name": "Untag Contact",
"description": "Remove a tag from a specific contact"
"description": ""
},
{
"name": "Tag Conversation",
"description": "Add a tag to a specific conversation"
"description": ""
},
{
"name": "Create Note",
"description": "Add a note to a specific contact"
"description": ""
},
{
"name": "Create Event",
"description": "Track a custom event for a contact in Intercom"
"description": ""
},
{
"name": "Attach Contact to Company",
"description": "Attach a contact to a company in Intercom"
"description": ""
},
{
"name": "Detach Contact from Company",
"description": "Remove a contact from a company in Intercom"
"description": ""
}
],
"operationCount": 31,
@@ -5797,7 +5721,7 @@
"operations": [
{
"name": "Read Issue",
"description": "Retrieve detailed information about a specific Jira issue"
"description": ""
},
{
"name": "Update Issue",
@@ -5809,19 +5733,19 @@
},
{
"name": "Delete Issue",
"description": "Delete a Jira issue"
"description": ""
},
{
"name": "Assign Issue",
"description": "Assign a Jira issue to a user"
"description": ""
},
{
"name": "Transition Issue",
"description": "Move a Jira issue between workflow statuses (e.g., To Do -> In Progress)"
"description": ""
},
{
"name": "Search Issues",
"description": "Search for Jira issues using JQL (Jira Query Language)"
"description": ""
},
{
"name": "Add Comment",
@@ -5869,11 +5793,11 @@
},
{
"name": "Create Issue Link",
"description": "Create a link relationship between two Jira issues"
"description": ""
},
{
"name": "Delete Issue Link",
"description": "Delete a link between two Jira issues"
"description": ""
},
{
"name": "Add Watcher",
@@ -5943,87 +5867,87 @@
"operations": [
{
"name": "Get Service Desks",
"description": "Get all service desks from Jira Service Management"
"description": ""
},
{
"name": "Get Request Types",
"description": "Get request types for a service desk in Jira Service Management"
"description": ""
},
{
"name": "Create Request",
"description": "Create a new service request in Jira Service Management"
"description": ""
},
{
"name": "Get Request",
"description": "Get a single service request from Jira Service Management"
"description": ""
},
{
"name": "Get Requests",
"description": "Get multiple service requests from Jira Service Management"
"description": ""
},
{
"name": "Add Comment",
"description": "Add a comment (public or internal) to a service request in Jira Service Management"
"description": ""
},
{
"name": "Get Comments",
"description": "Get comments for a service request in Jira Service Management"
"description": ""
},
{
"name": "Get Customers",
"description": "Get customers for a service desk in Jira Service Management"
"description": ""
},
{
"name": "Add Customer",
"description": "Add customers to a service desk in Jira Service Management"
"description": ""
},
{
"name": "Get Organizations",
"description": "Get organizations for a service desk in Jira Service Management"
"description": ""
},
{
"name": "Create Organization",
"description": "Create a new organization in Jira Service Management"
"description": ""
},
{
"name": "Add Organization",
"description": "Add an organization to a service desk in Jira Service Management"
"description": ""
},
{
"name": "Get Queues",
"description": "Get queues for a service desk in Jira Service Management"
"description": ""
},
{
"name": "Get SLA",
"description": "Get SLA information for a service request in Jira Service Management"
"description": ""
},
{
"name": "Get Transitions",
"description": "Get available transitions for a service request in Jira Service Management"
"description": ""
},
{
"name": "Transition Request",
"description": "Transition a service request to a new status in Jira Service Management"
"description": ""
},
{
"name": "Get Participants",
"description": "Get participants for a request in Jira Service Management"
"description": ""
},
{
"name": "Add Participants",
"description": "Add participants to a request in Jira Service Management"
"description": ""
},
{
"name": "Get Approvals",
"description": "Get approvals for a request in Jira Service Management"
"description": ""
},
{
"name": "Answer Approval",
"description": "Approve or decline an approval request in Jira Service Management"
"description": ""
},
{
"name": "Get Request Type Fields",
"description": "Get the fields required to create a request of a specific type in Jira Service Management"
"description": ""
}
],
"operationCount": 21,
@@ -7899,7 +7823,7 @@
},
{
"name": "Create File",
"description": "Upload a file to OneDrive"
"description": ""
},
{
"name": "Upload File",
@@ -7938,39 +7862,39 @@
"operations": [
{
"name": "Send Email",
"description": "Send emails using Outlook"
"description": ""
},
{
"name": "Draft Email",
"description": "Draft emails using Outlook"
"description": ""
},
{
"name": "Read Email",
"description": "Read emails from Outlook"
"description": ""
},
{
"name": "Forward Email",
"description": "Forward an existing Outlook message to specified recipients"
"description": ""
},
{
"name": "Move Email",
"description": "Move emails between Outlook folders"
"description": ""
},
{
"name": "Mark as Read",
"description": "Mark an Outlook message as read"
"description": ""
},
{
"name": "Mark as Unread",
"description": "Mark an Outlook message as unread"
"description": ""
},
{
"name": "Delete Email",
"description": "Delete an Outlook message (move to Deleted Items)"
"description": ""
},
{
"name": "Copy Email",
"description": "Copy an Outlook message to another folder"
"description": ""
}
],
"operationCount": 9,
@@ -8036,15 +7960,15 @@
"operations": [
{
"name": "Search",
"description": "Search the web using Parallel AI. Provides comprehensive search results with intelligent processing and content extraction."
"description": ""
},
{
"name": "Extract from URLs",
"description": "Extract targeted information from specific URLs using Parallel AI. Processes provided URLs to pull relevant content based on your objective."
"description": ""
},
{
"name": "Deep Research",
"description": "Conduct comprehensive deep research across the web using Parallel AI. Synthesizes information from multiple sources with citations. Can take up to 45 minutes to complete."
"description": ""
}
],
"operationCount": 3,
@@ -8094,7 +8018,7 @@
"operations": [
{
"name": "Generate Embeddings",
"description": "Generate embeddings from text using Pinecone"
"description": ""
},
{
"name": "Upsert Text",
@@ -8575,15 +8499,15 @@
"operations": [
{
"name": "Upsert",
"description": "Insert or update points in a Qdrant collection"
"description": ""
},
{
"name": "Search",
"description": "Search for similar vectors in a Qdrant collection"
"description": ""
},
{
"name": "Fetch",
"description": "Fetch points by ID from a Qdrant collection"
"description": ""
}
],
"operationCount": 3,
@@ -8947,101 +8871,6 @@
"integrationType": "ecommerce",
"tags": ["payments", "subscriptions"]
},
{
"type": "rippling",
"slug": "rippling",
"name": "Rippling",
"description": "Manage employees, leave, departments, and company data in Rippling",
"longDescription": "Integrate Rippling into your workflow. Manage employees, departments, teams, leave requests, work locations, groups, candidates, and company information.",
"bgColor": "#FFCC1C",
"iconName": "RipplingIcon",
"docsUrl": "https://docs.sim.ai/tools/rippling",
"operations": [
{
"name": "List Employees",
"description": "List all employees in Rippling with optional pagination"
},
{
"name": "Get Employee",
"description": "Get details for a specific employee by ID"
},
{
"name": "List Employees (Including Terminated)",
"description": "List all employees in Rippling including terminated employees with optional pagination"
},
{
"name": "List Departments",
"description": "List all departments in the Rippling organization"
},
{
"name": "List Teams",
"description": "List all teams in Rippling"
},
{
"name": "List Levels",
"description": "List all position levels in Rippling"
},
{
"name": "List Work Locations",
"description": "List all work locations in Rippling"
},
{
"name": "Get Company",
"description": "Get details for the current company in Rippling"
},
{
"name": "Get Company Activity",
"description": "Get activity events for the current company in Rippling"
},
{
"name": "List Custom Fields",
"description": "List all custom fields defined in Rippling"
},
{
"name": "Get Current User",
"description": "Get the current authenticated user details"
},
{
"name": "List Leave Requests",
"description": "List leave requests in Rippling with optional filtering by date range and status"
},
{
"name": "Approve/Decline Leave Request",
"description": "Approve or decline a leave request in Rippling"
},
{
"name": "List Leave Balances",
"description": "List leave balances for all employees in Rippling"
},
{
"name": "Get Leave Balance",
"description": "Get leave balance for a specific employee by role ID"
},
{
"name": "List Leave Types",
"description": "List company leave types configured in Rippling"
},
{
"name": "Create Group",
"description": "Create a new group in Rippling"
},
{
"name": "Update Group",
"description": "Update an existing group in Rippling"
},
{
"name": "Push Candidate",
"description": "Push a candidate to onboarding in Rippling"
}
],
"operationCount": 19,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
"category": "tools",
"integrationType": "hr",
"tags": ["hiring"]
},
{
"type": "s3",
"slug": "s3",
@@ -9201,7 +9030,7 @@
},
{
"name": "List Report Types",
"description": "Get a list of available report types"
"description": ""
},
{
"name": "List Dashboards",
@@ -9532,7 +9361,7 @@
},
{
"name": "Read List",
"description": "Get metadata (and optionally columns/items) for a SharePoint list"
"description": ""
},
{
"name": "Update List",
@@ -9709,11 +9538,11 @@
"operations": [
{
"name": "Send Message",
"description": "Send messages to Slack channels or direct messages. Supports Slack mrkdwn formatting."
"description": ""
},
{
"name": "Send Ephemeral Message",
"description": "Send an ephemeral message visible only to a specific user in a channel. Optionally reply in a thread. The message does not persist across sessions."
"description": ""
},
{
"name": "Create Canvas",
@@ -9721,7 +9550,7 @@
},
{
"name": "Read Messages",
"description": "Read the latest messages from Slack channels. Retrieve conversation history with filtering options."
"description": ""
},
{
"name": "Get Message",
@@ -9753,19 +9582,19 @@
},
{
"name": "Update Message",
"description": "Update a message previously sent by the bot in Slack"
"description": ""
},
{
"name": "Delete Message",
"description": "Delete a message previously sent by the bot in Slack"
"description": ""
},
{
"name": "Add Reaction",
"description": "Add an emoji reaction to a Slack message"
"description": ""
},
{
"name": "Remove Reaction",
"description": "Remove an emoji reaction from a Slack message"
"description": ""
},
{
"name": "Get Channel Info",
@@ -10606,67 +10435,67 @@
"operations": [
{
"name": "Get",
"description": "Get the value of a key from Upstash Redis."
"description": ""
},
{
"name": "Set",
"description": "Set the value of a key in Upstash Redis with an optional expiration time in seconds."
"description": ""
},
{
"name": "Delete",
"description": "Delete a key from Upstash Redis."
"description": ""
},
{
"name": "List Keys",
"description": "List keys matching a pattern in Upstash Redis. Defaults to listing all keys (*)."
"description": ""
},
{
"name": "HSET",
"description": "Set a field in a hash stored at a key in Upstash Redis."
"description": ""
},
{
"name": "HGET",
"description": "Get the value of a field in a hash stored at a key in Upstash Redis."
"description": ""
},
{
"name": "HGETALL",
"description": "Get all fields and values of a hash stored at a key in Upstash Redis."
"description": ""
},
{
"name": "INCR",
"description": "Atomically increment the integer value of a key by one in Upstash Redis. If the key does not exist, it is set to 0 before incrementing."
"description": ""
},
{
"name": "INCRBY",
"description": "Increment the integer value of a key by a given amount. Use a negative value to decrement. If the key does not exist, it is set to 0 before the operation."
"description": ""
},
{
"name": "EXISTS",
"description": "Check if a key exists in Upstash Redis. Returns true if the key exists, false otherwise."
"description": ""
},
{
"name": "SETNX",
"description": "Set the value of a key only if it does not already exist. Returns true if the key was set, false if it already existed."
"description": ""
},
{
"name": "LPUSH",
"description": "Prepend a value to the beginning of a list in Upstash Redis. Creates the list if it does not exist."
"description": ""
},
{
"name": "LRANGE",
"description": "Get a range of elements from a list in Upstash Redis. Use 0 and -1 for start and stop to get all elements."
"description": ""
},
{
"name": "EXPIRE",
"description": "Set a timeout on a key in Upstash Redis. After the timeout, the key is deleted."
"description": ""
},
{
"name": "TTL",
"description": "Get the remaining time to live of a key in Upstash Redis. Returns -1 if the key has no expiration, -2 if the key does not exist."
"description": ""
},
{
"name": "Command",
"description": "Execute an arbitrary Redis command against Upstash Redis. Pass the full command as a JSON array (e.g., ["
"description": ""
}
],
"operationCount": 16,
@@ -10987,23 +10816,23 @@
"operations": [
{
"name": "List Items",
"description": "List all items from a Webflow CMS collection"
"description": ""
},
{
"name": "Get Item",
"description": "Get a single item from a Webflow CMS collection"
"description": ""
},
{
"name": "Create Item",
"description": "Create a new item in a Webflow CMS collection"
"description": ""
},
{
"name": "Update Item",
"description": "Update an existing item in a Webflow CMS collection"
"description": ""
},
{
"name": "Delete Item",
"description": "Delete an item from a Webflow CMS collection"
"description": ""
}
],
"operationCount": 5,

View File

@@ -1,17 +1,15 @@
import { getNavBlogPosts } from '@/lib/blog/registry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
const url = getBaseUrl()
const orgJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Sim',
url,
logo: `${url}/logo/primary/small.png`,
url: 'https://sim.ai',
logo: 'https://sim.ai/logo/primary/small.png',
sameAs: ['https://x.com/simdotai'],
}
@@ -19,10 +17,10 @@ export default async function IntegrationsLayout({ children }: { children: React
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Sim',
url,
url: 'https://sim.ai',
potentialAction: {
'@type': 'SearchAction',
target: `${url}/search?q={search_term_string}`,
target: 'https://sim.ai/search?q={search_term_string}',
'query-input': 'required name=search_term_string',
},
}

View File

@@ -1,5 +1,4 @@
import type { Metadata } from 'next'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { IntegrationGrid } from './components/integration-grid'
import { RequestIntegrationModal } from './components/request-integration-modal'
import { blockTypeToIconMap } from './data/icon-mapping'
@@ -16,8 +15,6 @@ const INTEGRATION_COUNT = allIntegrations.length
*/
const TOP_NAMES = [...new Set(POPULAR_WORKFLOWS.flatMap((p) => [p.from, p.to]))].slice(0, 6)
const baseUrl = getBaseUrl()
export const metadata: Metadata = {
title: 'Integrations',
description: `Connect ${INTEGRATION_COUNT}+ apps and services with Sim's AI workflow automation. Build intelligent pipelines with ${TOP_NAMES.join(', ')}, and more.`,
@@ -31,26 +28,16 @@ export const metadata: Metadata = {
openGraph: {
title: 'Integrations for AI Workflow Automation | Sim',
description: `Connect ${INTEGRATION_COUNT}+ apps with Sim. Build AI-powered pipelines that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`,
url: `${baseUrl}/integrations`,
url: 'https://sim.ai/integrations',
type: 'website',
images: [
{
url: `${baseUrl}/opengraph-image.png`,
width: 1200,
height: 630,
alt: 'Sim Integrations for AI Workflow Automation',
},
],
images: [{ url: 'https://sim.ai/opengraph-image.png', width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: 'Integrations | Sim',
description: `Connect ${INTEGRATION_COUNT}+ apps with Sim's AI workflow automation.`,
images: [
{ url: `${baseUrl}/opengraph-image.png`, alt: 'Sim Integrations for AI Workflow Automation' },
],
},
alternates: { canonical: `${baseUrl}/integrations` },
alternates: { canonical: 'https://sim.ai/integrations' },
}
export default function IntegrationsPage() {
@@ -58,12 +45,12 @@ export default function IntegrationsPage() {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
{
'@type': 'ListItem',
position: 2,
name: 'Integrations',
item: `${baseUrl}/integrations`,
item: 'https://sim.ai/integrations',
},
],
}
@@ -73,7 +60,7 @@ export default function IntegrationsPage() {
'@type': 'ItemList',
name: 'Sim AI Workflow Integrations',
description: `Complete list of ${INTEGRATION_COUNT}+ integrations available in Sim for building AI-powered workflow automation.`,
url: `${baseUrl}/integrations`,
url: 'https://sim.ai/integrations',
numberOfItems: INTEGRATION_COUNT,
itemListElement: allIntegrations.map((integration, index) => ({
'@type': 'ListItem',
@@ -82,7 +69,7 @@ export default function IntegrationsPage() {
'@type': 'SoftwareApplication',
name: integration.name,
description: integration.description,
url: `${baseUrl}/integrations/${integration.slug}`,
url: `https://sim.ai/integrations/${integration.slug}`,
applicationCategory: 'BusinessApplication',
featureList: integration.operations.map((o) => o.name),
},

View File

@@ -21,7 +21,6 @@ export type AppSession = {
id?: string
userId?: string
activeOrganizationId?: string
impersonatedBy?: string | null
}
} | null

View File

@@ -54,23 +54,11 @@ html[data-sidebar-collapsed] .sidebar-container .text-small {
transition: opacity 60ms ease;
}
.sidebar-container .sidebar-collapse-show {
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out;
}
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
opacity: 0;
}
.sidebar-container[data-collapsed] .sidebar-collapse-show,
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-show {
opacity: 1;
pointer-events: auto;
}
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
display: none;
}

View File

@@ -0,0 +1,236 @@
import { db } from '@sim/db'
import { account, agent, agentDeployment, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentSlackDeployAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const DeploySlackSchema = z.object({
credentialId: z.string().min(1, 'Credential ID is required'),
channelIds: z.array(z.string().min(1)).default([]),
respondTo: z.enum(['mentions', 'all', 'threads', 'dm']),
botName: z.string().max(80).optional(),
replyInThread: z.boolean().default(true),
})
/**
* POST /api/agents/{agentId}/deployments/slack
* Configure a Slack deployment for an agent.
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = DeploySlackSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
if (parsed.data.respondTo !== 'dm' && parsed.data.channelIds.length === 0) {
return NextResponse.json({ error: 'At least one channel is required' }, { status: 400 })
}
const [agentRow] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!agentRow) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(agentRow.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { credentialId, channelIds, respondTo, botName, replyInThread } = parsed.data
const [credentialRow] = await db
.select({
id: credential.id,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
})
.from(credential)
.where(
and(
eq(credential.id, credentialId),
eq(credential.workspaceId, agentRow.workspaceId),
eq(credential.type, 'oauth'),
eq(credential.providerId, 'slack')
)
)
.limit(1)
if (!credentialRow?.accountId) {
return NextResponse.json({ error: 'Slack credential not found' }, { status: 404 })
}
const [accountRow] = await db
.select({ accessToken: account.accessToken })
.from(account)
.where(eq(account.id, credentialRow.accountId))
.limit(1)
if (!accountRow?.accessToken) {
return NextResponse.json({ error: 'Slack token not available' }, { status: 400 })
}
const authTest = await fetch('https://slack.com/api/auth.test', {
headers: { Authorization: `Bearer ${accountRow.accessToken}` },
})
const authData = await authTest.json()
if (!authData.ok) {
logger.warn(`[${requestId}] Slack auth.test failed`, { error: authData.error })
return NextResponse.json({ error: 'Failed to verify Slack token' }, { status: 400 })
}
const teamId: string = authData.team_id
const botUserId: string = authData.user_id ?? ''
if (!teamId || !botUserId) {
logger.warn(`[${requestId}] Slack auth.test returned incomplete identity`, {
teamId,
botUserId,
})
return NextResponse.json(
{ error: 'Could not determine Slack workspace or bot identity' },
{ status: 400 }
)
}
const existingDeployment = await db
.select({ id: agentDeployment.id })
.from(agentDeployment)
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.platform, 'slack')))
.limit(1)
const deploymentConfig: import('@/lib/agents/types').SlackDeploymentConfig = {
teamId,
botUserId,
channelIds,
respondTo,
replyInThread,
...(botName ? { botName } : {}),
}
let deploymentRow: typeof agentDeployment.$inferSelect
if (existingDeployment.length > 0) {
const [updated] = await db
.update(agentDeployment)
.set({
credentialId,
config: deploymentConfig,
isActive: true,
updatedAt: new Date(),
})
.where(eq(agentDeployment.id, existingDeployment[0].id))
.returning()
deploymentRow = updated
} else {
const [inserted] = await db
.insert(agentDeployment)
.values({
id: uuidv4(),
agentId,
platform: 'slack',
credentialId,
config: deploymentConfig,
isActive: true,
})
.returning()
deploymentRow = inserted
}
await db
.update(agent)
.set({ isDeployed: true, deployedAt: new Date(), updatedAt: new Date() })
.where(eq(agent.id, agentId))
logger.info(`[${requestId}] Agent ${agentId} deployed to Slack`, { teamId, channelIds })
return NextResponse.json({ success: true, data: deploymentRow })
} catch (error) {
logger.error(`[${requestId}] Failed to deploy agent ${agentId} to Slack`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/agents/{agentId}/deployments/slack
* Remove a Slack deployment from an agent.
*/
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [agentRow] = await db
.select({ id: agent.id, workspaceId: agent.workspaceId })
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!agentRow) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(agentRow.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
await db
.delete(agentDeployment)
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.platform, 'slack')))
const remainingDeployments = await db
.select({ id: agentDeployment.id })
.from(agentDeployment)
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.isActive, true)))
.limit(1)
if (remainingDeployments.length === 0) {
await db
.update(agent)
.set({ isDeployed: false, updatedAt: new Date() })
.where(eq(agent.id, agentId))
}
logger.info(`[${requestId}] Slack deployment removed for agent ${agentId}`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Failed to remove Slack deployment for agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,99 @@
import { db } from '@sim/db'
import { agent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { executeAgent } from '@/lib/agents/execute'
import type { AgentConfig } from '@/lib/agents/types'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentExecuteAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const ExecuteAgentSchema = z.object({
message: z.string().min(1, 'Message is required'),
conversationId: z.string().optional(),
})
/**
* POST /api/agents/{agentId}/execute
* Test-execute an agent from the UI. Requires an active session.
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = ExecuteAgentSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const [row] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { message, conversationId } = parsed.data
const memoryConversationId = conversationId ?? `agent:${agentId}:test:${session.user.id}`
logger.info(`[${requestId}] Executing agent ${agentId}`, { userId: session.user.id })
const result = await executeAgent({
config: row.config as AgentConfig,
message,
conversationId: memoryConversationId,
agentId,
workspaceId: row.workspaceId,
userId: session.user.id,
isDeployedContext: false,
})
const streamingResult =
result && typeof result === 'object' && 'stream' in result
? (result as { stream: unknown }).stream
: null
if (streamingResult instanceof ReadableStream) {
return new NextResponse(streamingResult, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
return NextResponse.json({ success: true, data: result as Record<string, unknown> })
} catch (error) {
logger.error(`[${requestId}] Agent execution failed for ${agentId}`, { error })
return NextResponse.json({ error: 'Execution failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,171 @@
import { db } from '@sim/db'
import { agent, agentDeployment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } 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 { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentByIdAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const UpdateAgentSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().optional(),
config: z.record(z.unknown()).optional(),
isDeployed: z.boolean().optional(),
})
/**
* GET /api/agents/{agentId}
* Get a single agent with its deployments.
*/
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [row] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const deployments = await db
.select()
.from(agentDeployment)
.where(eq(agentDeployment.agentId, agentId))
return NextResponse.json({ success: true, data: { ...row, deployments } })
} catch (error) {
logger.error(`[${requestId}] Failed to get agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PATCH /api/agents/{agentId}
* Update an agent's name, description, or config.
*/
export async function PATCH(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = UpdateAgentSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const [row] = await db
.select({ id: agent.id, workspaceId: agent.workspaceId })
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (parsed.data.name !== undefined) updates.name = parsed.data.name
if (parsed.data.description !== undefined) updates.description = parsed.data.description
if (parsed.data.config !== undefined) updates.config = parsed.data.config
if (parsed.data.isDeployed !== undefined) {
updates.isDeployed = parsed.data.isDeployed
if (parsed.data.isDeployed) updates.deployedAt = new Date()
}
const [updated] = await db.update(agent).set(updates).where(eq(agent.id, agentId)).returning()
logger.info(`[${requestId}] Agent updated`, { agentId })
return NextResponse.json({ success: true, data: updated })
} catch (error) {
logger.error(`[${requestId}] Failed to update agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/agents/{agentId}
* Soft-delete an agent and deactivate all its deployments.
*/
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [row] = await db
.select({ id: agent.id, workspaceId: agent.workspaceId })
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
await db
.update(agentDeployment)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(agentDeployment.agentId, agentId))
await db
.update(agent)
.set({ archivedAt: new Date(), isDeployed: false, updatedAt: new Date() })
.where(eq(agent.id, agentId))
logger.info(`[${requestId}] Agent archived`, { agentId })
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Failed to delete agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,128 @@
import { db } from '@sim/db'
import { agent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNotNull, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentsAPI')
export const dynamic = 'force-dynamic'
type AgentQueryScope = 'active' | 'archived' | 'all'
const CreateAgentSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
name: z.string().min(1, 'Name is required').max(100, 'Name must be 100 characters or fewer'),
description: z.string().optional(),
config: z.record(z.unknown()).default({}),
})
/**
* GET /api/agents?workspaceId={id}&scope=active|archived|all
* List agents for a workspace.
*/
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized agents list access`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
const scopeParam = (searchParams.get('scope') ?? 'active') as AgentQueryScope
if (!workspaceId) {
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
if (!['active', 'archived', 'all'].includes(scopeParam)) {
return NextResponse.json({ error: 'Invalid scope' }, { status: 400 })
}
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!access.exists) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const conditions = [eq(agent.workspaceId, workspaceId)]
if (scopeParam === 'active') conditions.push(isNull(agent.archivedAt))
if (scopeParam === 'archived') conditions.push(isNotNull(agent.archivedAt))
const agents = await db
.select()
.from(agent)
.where(and(...conditions))
.orderBy(agent.updatedAt)
return NextResponse.json({ success: true, data: agents })
} catch (error) {
logger.error(`[${requestId}] Failed to list agents`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/agents
* Create a new agent.
*/
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized agent create attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = CreateAgentSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const { workspaceId, name, description, config } = parsed.data
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!access.exists) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const [created] = await db
.insert(agent)
.values({
id: uuidv4(),
workspaceId,
createdBy: session.user.id,
name,
description,
config,
})
.returning()
logger.info(`[${requestId}] Agent created`, { agentId: created.id, workspaceId })
return NextResponse.json({ success: true, data: created }, { status: 201 })
} catch (error) {
logger.error(`[${requestId}] Failed to create agent`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,489 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { account, agent, agentConversation, agentDeployment, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { executeAgent } from '@/lib/agents/execute'
import type { AgentConfig, SlackDeploymentConfig } from '@/lib/agents/types'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { handleSlackChallenge } from '@/lib/webhooks/utils.server'
const logger = createLogger('AgentSlackWebhook')
export const dynamic = 'force-dynamic'
/** Verify the Slack request signature using HMAC-SHA256. */
async function verifySlackSignature(
signingSecret: string,
rawBody: string,
timestamp: string,
signature: string
): Promise<boolean> {
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - Number.parseInt(timestamp, 10)) > 300) return false
const baseString = `v0:${timestamp}:${rawBody}`
const hmac = crypto.createHmac('sha256', signingSecret)
hmac.update(baseString)
const computed = `v0=${hmac.digest('hex')}`
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature))
}
/**
* Consume a StreamingExecution ReadableStream (SSE-formatted).
* Calls onChunk with (delta, accumulated) on each new token.
* Returns the final complete text.
*/
async function consumeAgentStream(
stream: ReadableStream,
onChunk?: (delta: string, accumulated: string) => void | Promise<void>
): Promise<string> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buf = ''
let accumulated = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]') return accumulated
try {
const parsed = JSON.parse(data) as { content?: string }
if (parsed.content) {
const delta = parsed.content
accumulated += delta
await onChunk?.(delta, accumulated)
}
} catch {}
}
}
return accumulated
}
/** Call Slack's chat.postMessage. Returns the message ts or null on failure. */
async function postMessage(
botToken: string,
payload: {
channel: string
text: string
thread_ts?: string
username?: string
}
): Promise<string | null> {
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = (await res.json()) as { ok: boolean; ts?: string; error?: string }
if (!data.ok) {
logger.warn('chat.postMessage failed', { error: data.error })
return null
}
return data.ts ?? null
}
/** Call Slack's chat.update. */
async function updateMessage(
botToken: string,
channel: string,
ts: string,
text: string,
username?: string
): Promise<void> {
const res = await fetch('https://slack.com/api/chat.update', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, ts, text, ...(username ? { username } : {}) }),
})
const data = (await res.json()) as { ok: boolean; error?: string }
if (!data.ok) logger.warn('chat.update failed', { error: data.error })
}
/**
* Start a native Slack stream (Oct 2025 streaming API).
* Returns the stream ts, or null if unsupported (e.g. Enterprise Grid).
*/
async function startStream(
botToken: string,
channel: string,
threadTs: string,
recipientUserId: string,
recipientTeamId: string,
username?: string
): Promise<string | null> {
const res = await fetch('https://slack.com/api/chat.startStream', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
channel,
thread_ts: threadTs,
recipient_user_id: recipientUserId,
recipient_team_id: recipientTeamId,
...(username ? { username } : {}),
}),
})
const data = (await res.json()) as { ok: boolean; ts?: string; error?: string }
if (!data.ok) {
// enterprise_is_restricted = Enterprise Grid; fall back silently
if (data.error !== 'enterprise_is_restricted') {
logger.warn('chat.startStream failed', { error: data.error })
}
return null
}
return data.ts ?? null
}
/** Append a markdown chunk to a native Slack stream. */
async function appendStream(
botToken: string,
channel: string,
ts: string,
markdownText: string
): Promise<void> {
const res = await fetch('https://slack.com/api/chat.appendStream', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, ts, markdown_text: markdownText }),
})
const data = (await res.json()) as { ok: boolean; error?: string }
if (!data.ok) logger.warn('chat.appendStream failed', { error: data.error })
}
/** Finalize a native Slack stream. */
async function stopStream(botToken: string, channel: string, ts: string): Promise<void> {
const res = await fetch('https://slack.com/api/chat.stopStream', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, ts }),
})
const data = (await res.json()) as { ok: boolean; error?: string }
if (!data.ok) logger.warn('chat.stopStream failed', { error: data.error })
}
/**
* POST /api/agents/slack/webhook
* Receives Slack event callbacks for all agent deployments.
* Responds 200 immediately and processes events asynchronously.
*/
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const signingSecret = env.SLACK_SIGNING_SECRET
if (!signingSecret) {
logger.error(`[${requestId}] SLACK_SIGNING_SECRET is not configured`)
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
}
const rawBody = await request.text()
const timestamp = request.headers.get('x-slack-request-timestamp') ?? ''
const signature = request.headers.get('x-slack-signature') ?? ''
const isValid = await verifySlackSignature(signingSecret, rawBody, timestamp, signature)
if (!isValid) {
logger.warn(`[${requestId}] Invalid Slack signature`)
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
let body: Record<string, unknown>
try {
body = JSON.parse(rawBody) as Record<string, unknown>
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const challengeResponse = handleSlackChallenge(body)
if (challengeResponse) return challengeResponse
if (body.type !== 'event_callback') {
return new NextResponse(null, { status: 200 })
}
// Deduplicate: if Slack retried (infrastructure-level duplicate delivery), skip
const retryNum = request.headers.get('x-slack-retry-num')
if (retryNum) {
return new NextResponse(null, { status: 200 })
}
const event = (body.event ?? {}) as Record<string, unknown>
const teamId = String(body.team_id ?? '')
const channel = String(event.channel ?? '')
const eventTs = String(event.ts ?? '')
const threadTs = String(event.thread_ts ?? '')
const text = String(event.text ?? '')
const userId = String(event.user ?? '')
const botId = event.bot_id as string | undefined
const subtype = event.subtype as string | undefined
const eventType = String(event.type ?? '')
// Ignore bot messages (our own replies or other bots)
if (botId || subtype === 'bot_message') {
return new NextResponse(null, { status: 200 })
}
// Respond 200 immediately — Slack never retries a 200, so no header needed
void processSlackEvent({ requestId, teamId, channel, eventTs, threadTs, text, userId, eventType })
return new NextResponse(null, { status: 200 })
}
interface SlackEventContext {
requestId: string
teamId: string
channel: string
eventTs: string
threadTs: string
text: string
userId: string
eventType: string
}
async function processSlackEvent(ctx: SlackEventContext): Promise<void> {
const { requestId, teamId, channel, eventTs, threadTs, text, userId, eventType } = ctx
try {
const deployments = await db
.select({ deployment: agentDeployment, agentRow: agent })
.from(agentDeployment)
.innerJoin(agent, and(eq(agent.id, agentDeployment.agentId), isNull(agent.archivedAt)))
.where(and(eq(agentDeployment.platform, 'slack'), eq(agentDeployment.isActive, true)))
const isDm = channel.startsWith('D')
const match = deployments.find((row) => {
const config = row.deployment.config as SlackDeploymentConfig
if (config.teamId !== teamId) return false
const respondTo = config.respondTo
if (respondTo === 'dm') return isDm
if (!config.channelIds.includes(channel)) return false
if (respondTo === 'mentions' && eventType !== 'app_mention') return false
if (respondTo === 'threads' && !threadTs) return false
return true
})
if (!match) {
logger.info(`[${requestId}] No agent matched for team=${teamId} channel=${channel}`)
return
}
const { deployment, agentRow } = match
const agentId = agentRow.id
const workspaceId = agentRow.workspaceId
const config = deployment.config as SlackDeploymentConfig
let botToken: string | undefined
if (deployment.credentialId) {
const [row] = await db
.select({ accessToken: account.accessToken })
.from(credential)
.innerJoin(account, eq(account.id, credential.accountId))
.where(eq(credential.id, deployment.credentialId))
.limit(1)
botToken = row?.accessToken ?? undefined
}
if (!botToken) {
logger.warn(`[${requestId}] No bot token for agent ${agentId}`)
return
}
// Thread → thread-scoped memory; DM or main channel → channel-scoped memory
const externalId = threadTs ? `${channel}:${threadTs}` : channel
const conversationId = `agent:${agentId}:slack:${externalId}`
await db
.insert(agentConversation)
.values({
id: uuidv4(),
agentId,
platform: 'slack',
externalId,
conversationId,
metadata: { channel, threadTs, teamId },
})
.onConflictDoUpdate({
target: [
agentConversation.agentId,
agentConversation.platform,
agentConversation.externalId,
],
set: { updatedAt: new Date(), metadata: { channel, threadTs, teamId } },
})
// Strip bot mention (use stored botUserId if available, else strip any @mention)
const mentionPattern = config.botUserId
? new RegExp(`<@${config.botUserId}>`, 'g')
: /<@[UW][A-Z0-9]+>/g
const cleanedText = text.replace(mentionPattern, '').trim()
if (!cleanedText) return
const replyInThread = config.replyInThread !== false
const replyThreadTs = replyInThread ? threadTs || eventTs : undefined
const botName = config.botName || undefined
logger.info(`[${requestId}] Executing agent ${agentId} for Slack event`, {
channel,
threadTs,
eventType,
})
const timeout = AbortSignal.timeout(30_000)
const result = await executeAgent({
config: agentRow.config as AgentConfig,
message: cleanedText,
conversationId,
agentId,
workspaceId,
isDeployedContext: true,
abortSignal: timeout,
})
const streamingResult =
result && typeof result === 'object' && 'stream' in result
? (result as { stream: ReadableStream }).stream
: null
let responseText: string
if (streamingResult instanceof ReadableStream) {
responseText = await streamResponse({
botToken,
channel,
replyThreadTs,
teamId,
userId,
botName,
stream: streamingResult,
requestId,
})
} else {
responseText = String((result as Record<string, unknown>).content ?? '')
if (responseText) {
await postMessage(botToken, {
channel,
text: responseText,
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
}
}
if (!responseText) {
await postMessage(botToken, {
channel,
text: '_No response._',
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
return
}
logger.info(`[${requestId}] Agent ${agentId} responded to Slack event`)
} catch (error) {
logger.error(`[${requestId}] Error processing Slack event`, { error })
}
}
interface StreamResponseParams {
botToken: string
channel: string
replyThreadTs: string | undefined
teamId: string
userId: string
botName: string | undefined
stream: ReadableStream
requestId: string
}
/**
* Deliver a streaming agent response to Slack.
*
* Prefers the native streaming API (chat.startStream / appendStream / stopStream)
* introduced in October 2025, which renders a real-time typewriter effect in
* Slack clients and uses the higher Tier 4 rate limit (100+/min).
*
* Falls back to chat.postMessage + throttled chat.update (~1/sec) when:
* - The workspace is on Enterprise Grid (startStream returns enterprise_is_restricted)
* - No thread context is available (startStream requires thread_ts)
*/
async function streamResponse({
botToken,
channel,
replyThreadTs,
teamId,
userId,
botName,
stream,
requestId,
}: StreamResponseParams): Promise<string> {
// Native streaming API requires a thread context
if (replyThreadTs) {
const streamTs = await startStream(botToken, channel, replyThreadTs, userId, teamId, botName)
if (streamTs) {
// Native streaming path — Tier 4 (100+/min), flush every ~600ms
let pendingDelta = ''
let lastFlushTime = 0
const responseText = await consumeAgentStream(stream, async (delta) => {
pendingDelta += delta
const now = Date.now()
if (now - lastFlushTime >= 600 && pendingDelta) {
lastFlushTime = now
const toSend = pendingDelta
pendingDelta = ''
await appendStream(botToken, channel, streamTs, toSend)
}
})
// Flush remaining buffer
if (pendingDelta) {
await appendStream(botToken, channel, streamTs, pendingDelta)
}
await stopStream(botToken, channel, streamTs)
logger.info(`[${requestId}] Streamed via native streaming API`)
return responseText
}
}
// Fallback: post placeholder → throttled chat.update (~1/sec, Tier 3)
const placeholderTs = await postMessage(botToken, {
channel,
text: '_Thinking…_',
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
let lastUpdateTime = 0
const responseText = await consumeAgentStream(stream, async (_, accumulated) => {
const now = Date.now()
if (placeholderTs && now - lastUpdateTime >= 1200) {
lastUpdateTime = now
void updateMessage(botToken, channel, placeholderTs, `${accumulated}`, botName)
}
})
if (placeholderTs) {
await updateMessage(botToken, channel, placeholderTs, responseText || '_No response._', botName)
} else if (responseText) {
await postMessage(botToken, {
channel,
text: responseText,
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
}
logger.info(`[${requestId}] Streamed via chat.update fallback`)
return responseText
}

View File

@@ -1,12 +1,11 @@
import { db } from '@sim/db'
import { subscription as subscriptionTable, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, or } from 'drizzle-orm'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('BillingPortal')
@@ -46,7 +45,7 @@ export async function POST(request: NextRequest) {
and(
eq(subscriptionTable.referenceId, organizationId),
or(
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.cancelAtPeriodEnd, true)
)
)

View File

@@ -1,15 +1,99 @@
import { db } from '@sim/db'
import { member } from '@sim/db/schema'
import { member, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getPlanTierCredits } from '@/lib/billing/plan-helpers'
/**
* Gets the effective billing blocked status for a user.
* If user is in an org, also checks if the org owner is blocked.
*/
async function getEffectiveBillingStatus(userId: string): Promise<{
billingBlocked: boolean
billingBlockedReason: 'payment_failed' | 'dispute' | null
blockedByOrgOwner: boolean
}> {
// Check user's own status
const userStatsRows = await db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
const userBlocked = userStatsRows.length > 0 ? !!userStatsRows[0].blocked : false
const userBlockedReason = userStatsRows.length > 0 ? userStatsRows[0].blockedReason : null
if (userBlocked) {
return {
billingBlocked: true,
billingBlockedReason: userBlockedReason,
blockedByOrgOwner: false,
}
}
// Check if user is in an org where owner is blocked
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
// Fetch all org owners in parallel
const ownerResults = await Promise.all(
memberships.map((m) =>
db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
.limit(1)
)
)
// Collect owner IDs that are not the current user
const otherOwnerIds = ownerResults
.filter((owners) => owners.length > 0 && owners[0].userId !== userId)
.map((owners) => owners[0].userId)
if (otherOwnerIds.length > 0) {
// Fetch all owner stats in parallel
const ownerStatsResults = await Promise.all(
otherOwnerIds.map((ownerId) =>
db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, ownerId))
.limit(1)
)
)
for (const stats of ownerStatsResults) {
if (stats.length > 0 && stats[0].blocked) {
return {
billingBlocked: true,
billingBlockedReason: stats[0].blockedReason,
blockedByOrgOwner: true,
}
}
}
}
return {
billingBlocked: false,
billingBlockedReason: null,
blockedByOrgOwner: false,
}
}
const logger = createLogger('UnifiedBillingAPI')
/**

View File

@@ -5,17 +5,12 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { writeBillingInterval } from '@/lib/billing/core/subscription'
import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import {
hasUsableSubscriptionAccess,
hasUsableSubscriptionStatus,
} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('SwitchPlan')
@@ -65,11 +60,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
}
const billingStatus = await getEffectiveBillingStatus(userId)
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) {
return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
}
if (isEnterprise(sub.plan) || isEnterprise(targetPlanName)) {
return NextResponse.json(
{ error: 'Enterprise plan changes must be handled via support' },
@@ -101,7 +91,7 @@ export async function POST(request: NextRequest) {
const stripe = requireStripeClient()
const stripeSubscription = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId)
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
if (stripeSubscription.status !== 'active') {
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
}

View File

@@ -1,8 +1,10 @@
import { db } from '@sim/db'
import { userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { sql } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { recordUsage } from '@/lib/billing/core/usage-log'
import { logModelUsage } from '@/lib/billing/core/usage-log'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { checkInternalApiKey } from '@/lib/copilot/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -85,41 +87,55 @@ export async function POST(req: NextRequest) {
source,
})
// Check if user stats record exists (same as ExecutionLogger)
const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
if (userStatsRecords.length === 0) {
logger.error(
`[${requestId}] User stats record not found - should be created during onboarding`,
{
userId,
}
)
return NextResponse.json({ error: 'User stats record not found' }, { status: 500 })
}
const totalTokens = inputTokens + outputTokens
const additionalStats: Record<string, ReturnType<typeof sql>> = {
const updateFields: Record<string, unknown> = {
totalCost: sql`total_cost + ${cost}`,
currentPeriodCost: sql`current_period_cost + ${cost}`,
totalCopilotCost: sql`total_copilot_cost + ${cost}`,
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${cost}`,
totalCopilotCalls: sql`total_copilot_calls + 1`,
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
lastActive: new Date(),
}
if (isMcp) {
additionalStats.totalMcpCopilotCost = sql`total_mcp_copilot_cost + ${cost}`
additionalStats.currentPeriodMcpCopilotCost = sql`current_period_mcp_copilot_cost + ${cost}`
additionalStats.totalMcpCopilotCalls = sql`total_mcp_copilot_calls + 1`
updateFields.totalMcpCopilotCost = sql`total_mcp_copilot_cost + ${cost}`
updateFields.currentPeriodMcpCopilotCost = sql`current_period_mcp_copilot_cost + ${cost}`
updateFields.totalMcpCopilotCalls = sql`total_mcp_copilot_calls + 1`
}
await recordUsage({
userId,
entries: [
{
category: 'model',
source,
description: model,
cost,
metadata: { inputTokens, outputTokens },
},
],
additionalStats,
})
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
logger.info(`[${requestId}] Recorded usage`, {
logger.info(`[${requestId}] Updated user stats record`, {
userId,
addedCost: cost,
source,
})
// Log usage for complete audit trail with the original source for visibility
await logModelUsage({
userId,
source,
model,
inputTokens,
outputTokens,
cost,
})
// Check if user has hit overage threshold and bill incrementally
await checkAndBillOverageThreshold(userId)

View File

@@ -6,7 +6,7 @@ import { env } from '@/lib/core/config/env'
const logger = createLogger('CopilotAutoAllowedToolsAPI')
/** Headers for server-to-server calls to the copilot backend. */
/** Headers for server-to-server calls to the Go copilot backend. */
function copilotHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -36,7 +36,7 @@ export async function GET() {
)
if (!res.ok) {
logger.warn('Copilot returned error for list auto-allowed', { status: res.status })
logger.warn('Go backend returned error for list auto-allowed', { status: res.status })
return NextResponse.json({ autoAllowedTools: [] })
}
@@ -73,7 +73,7 @@ export async function POST(request: NextRequest) {
})
if (!res.ok) {
logger.warn('Copilot returned error for add auto-allowed', { status: res.status })
logger.warn('Go backend returned error for add auto-allowed', { status: res.status })
return NextResponse.json({ error: 'Failed to add tool' }, { status: 500 })
}
@@ -113,7 +113,7 @@ export async function DELETE(request: NextRequest) {
)
if (!res.ok) {
logger.warn('Copilot returned error for remove auto-allowed', { status: res.status })
logger.warn('Go backend returned error for remove auto-allowed', { status: res.status })
return NextResponse.json({ error: 'Failed to remove tool' }, { status: 500 })
}

View File

@@ -1,11 +1,6 @@
import { NextResponse } from 'next/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/chat-streaming'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { abortActiveStream } from '@/lib/copilot/chat-streaming'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000
export async function POST(request: Request) {
const { userId: authenticatedUserId, isAuthenticated } =
@@ -17,48 +12,11 @@ export async function POST(request: Request) {
const body = await request.json().catch(() => ({}))
const streamId = typeof body.streamId === 'string' ? body.streamId : ''
let chatId = typeof body.chatId === 'string' ? body.chatId : ''
if (!streamId) {
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
}
if (!chatId) {
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch(() => null)
if (run?.chatId) {
chatId = run.chatId
}
}
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), GO_EXPLICIT_ABORT_TIMEOUT_MS)
const response = await fetch(`${SIM_AGENT_API_URL}/api/streams/explicit-abort`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
messageId: streamId,
userId: authenticatedUserId,
...(chatId ? { chatId } : {}),
}),
}).finally(() => clearTimeout(timeout))
if (!response.ok) {
throw new Error(`Explicit abort marker request failed: ${response.status}`)
}
} catch {
// best effort: local abort should still proceed even if Go marker fails
}
const aborted = await abortActiveStream(streamId)
if (chatId) {
await waitForPendingChatStream(chatId, GO_EXPLICIT_ABORT_TIMEOUT_MS + 1000, streamId).catch(
() => false
)
}
const aborted = abortActiveStream(streamId)
return NextResponse.json({ aborted })
}

View File

@@ -0,0 +1,65 @@
import { db } from '@sim/db'
import { copilotChats } 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 { getSession } from '@/lib/auth'
import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
import { taskPubSub } from '@/lib/copilot/task-events'
const logger = createLogger('RenameChatAPI')
const RenameChatSchema = z.object({
chatId: z.string().min(1),
title: z.string().min(1).max(200),
})
export async function PATCH(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { chatId, title } = RenameChatSchema.parse(body)
const chat = await getAccessibleCopilotChat(chatId, session.user.id)
if (!chat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
const now = new Date()
const [updated] = await db
.update(copilotChats)
.set({ title, updatedAt: now, lastSeenAt: now })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
.returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId })
if (!updated) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
logger.info('Chat renamed', { chatId, title })
if (updated.workspaceId) {
taskPubSub?.publishStatusChanged({
workspaceId: updated.workspaceId,
chatId,
type: 'renamed',
})
}
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error('Error renaming chat:', error)
return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 })
}
}

View File

@@ -8,18 +8,14 @@ import { getSession } from '@/lib/auth'
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import {
acquirePendingChatStream,
createSSEStream,
releasePendingChatStream,
requestChatTitle,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/chat-streaming'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { resolveActiveResourceContext } from '@/lib/copilot/process-contents'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -48,13 +44,6 @@ const FileAttachmentSchema = z.object({
size: z.number(),
})
const ResourceAttachmentSchema = z.object({
type: z.enum(['workflow', 'table', 'file', 'knowledgebase']),
id: z.string().min(1),
title: z.string().optional(),
active: z.boolean().optional(),
})
const ChatMessageSchema = z.object({
message: z.string().min(1, 'Message is required'),
userMessageId: z.string().optional(),
@@ -69,7 +58,6 @@ const ChatMessageSchema = z.object({
stream: z.boolean().optional().default(true),
implicitFeedback: z.string().optional(),
fileAttachments: z.array(FileAttachmentSchema).optional(),
resourceAttachments: z.array(ResourceAttachmentSchema).optional(),
provider: z.string().optional(),
contexts: z
.array(
@@ -110,10 +98,6 @@ const ChatMessageSchema = z.object({
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
let actualChatId: string | undefined
let pendingChatStreamAcquired = false
let pendingChatStreamHandedOff = false
let pendingChatStreamID: string | undefined
try {
// Get session to access user information including name
@@ -140,7 +124,6 @@ export async function POST(req: NextRequest) {
stream,
implicitFeedback,
fileAttachments,
resourceAttachments,
provider,
contexts,
commands,
@@ -176,48 +159,37 @@ export async function POST(req: NextRequest) {
const workflowId = resolved.workflowId
const workflowResolvedName = resolved.workflowName
// Resolve workspace from workflow so it can be sent as implicit context to the copilot.
// Resolve workspace from workflow so it can be sent as implicit context to the Go backend.
let resolvedWorkspaceId: string | undefined
try {
const { getWorkflowById } = await import('@/lib/workflows/utils')
const wf = await getWorkflowById(workflowId)
resolvedWorkspaceId = wf?.workspaceId ?? undefined
} catch {
logger.warn(
appendCopilotLogContext('Failed to resolve workspaceId from workflow', {
requestId: tracker.requestId,
messageId: userMessageId,
})
)
logger.warn(`[${tracker.requestId}] Failed to resolve workspaceId from workflow`)
}
const userMessageIdToUse = userMessageId || crypto.randomUUID()
try {
logger.error(
appendCopilotLogContext('Received chat POST', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
workflowId,
hasContexts: Array.isArray(normalizedContexts),
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
contextsPreview: Array.isArray(normalizedContexts)
? normalizedContexts.map((c: any) => ({
kind: c?.kind,
chatId: c?.chatId,
workflowId: c?.workflowId,
executionId: (c as any)?.executionId,
label: c?.label,
}))
: undefined,
}
)
logger.info(`[${tracker.requestId}] Received chat POST`, {
workflowId,
hasContexts: Array.isArray(normalizedContexts),
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
contextsPreview: Array.isArray(normalizedContexts)
? normalizedContexts.map((c: any) => ({
kind: c?.kind,
chatId: c?.chatId,
workflowId: c?.workflowId,
executionId: (c as any)?.executionId,
label: c?.label,
}))
: undefined,
})
} catch {}
let currentChat: any = null
let conversationHistory: any[] = []
actualChatId = chatId
let actualChatId = chatId
const selectedModel = model || 'claude-opus-4-6'
if (chatId || createNewChat) {
@@ -250,76 +222,22 @@ export async function POST(req: NextRequest) {
actualChatId
)
agentContexts = processed
logger.error(
appendCopilotLogContext('Contexts processed for request', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
processedCount: agentContexts.length,
kinds: agentContexts.map((c) => c.type),
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
}
)
logger.info(`[${tracker.requestId}] Contexts processed for request`, {
processedCount: agentContexts.length,
kinds: agentContexts.map((c) => c.type),
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
})
if (
Array.isArray(normalizedContexts) &&
normalizedContexts.length > 0 &&
agentContexts.length === 0
) {
logger.warn(
appendCopilotLogContext(
'Contexts provided but none processed. Check executionId for logs contexts.',
{
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}
)
`[${tracker.requestId}] Contexts provided but none processed. Check executionId for logs contexts.`
)
}
} catch (e) {
logger.error(
appendCopilotLogContext('Failed to process contexts', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
e
)
}
}
if (
Array.isArray(resourceAttachments) &&
resourceAttachments.length > 0 &&
resolvedWorkspaceId
) {
const results = await Promise.allSettled(
resourceAttachments.map(async (r) => {
const ctx = await resolveActiveResourceContext(
r.type,
r.id,
resolvedWorkspaceId!,
authenticatedUserId,
actualChatId
)
if (!ctx) return null
return {
...ctx,
tag: r.active ? '@active_tab' : '@open_tab',
}
})
)
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
logger.error(
appendCopilotLogContext('Failed to resolve resource attachment', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
result.reason
)
}
logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
}
}
@@ -357,43 +275,22 @@ export async function POST(req: NextRequest) {
)
try {
logger.error(
appendCopilotLogContext('About to call Sim Agent', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
hasContext: agentContexts.length > 0,
contextCount: agentContexts.length,
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
messageLength: message.length,
mode: effectiveMode,
hasTools: Array.isArray(requestPayload.tools),
toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0,
hasBaseTools: Array.isArray(requestPayload.baseTools),
baseToolCount: Array.isArray(requestPayload.baseTools)
? requestPayload.baseTools.length
: 0,
hasCredentials: !!requestPayload.credentials,
}
)
logger.info(`[${tracker.requestId}] About to call Sim Agent`, {
hasContext: agentContexts.length > 0,
contextCount: agentContexts.length,
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
messageLength: message.length,
mode: effectiveMode,
hasTools: Array.isArray(requestPayload.tools),
toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0,
hasBaseTools: Array.isArray(requestPayload.baseTools),
baseToolCount: Array.isArray(requestPayload.baseTools)
? requestPayload.baseTools.length
: 0,
hasCredentials: !!requestPayload.credentials,
})
} catch {}
if (stream && actualChatId) {
const acquired = await acquirePendingChatStream(actualChatId, userMessageIdToUse)
if (!acquired) {
return NextResponse.json(
{
error:
'A response is already in progress for this chat. Wait for it to finish or use Stop.',
},
{ status: 409 }
)
}
pendingChatStreamAcquired = true
pendingChatStreamID = userMessageIdToUse
}
if (actualChatId) {
const userMsg = {
id: userMessageIdToUse,
@@ -440,7 +337,6 @@ export async function POST(req: NextRequest) {
titleProvider: provider,
requestId: tracker.requestId,
workspaceId: resolvedWorkspaceId,
pendingChatStreamAlreadyRegistered: Boolean(actualChatId && stream),
orchestrateOptions: {
userId: authenticatedUserId,
workflowId,
@@ -452,7 +348,6 @@ export async function POST(req: NextRequest) {
interactive: true,
onComplete: async (result: OrchestratorResult) => {
if (!actualChatId) return
if (!result.success) return
const assistantMessage: Record<string, unknown> = {
id: crypto.randomUUID(),
@@ -520,21 +415,14 @@ export async function POST(req: NextRequest) {
.where(eq(copilotChats.id, actualChatId))
}
} catch (error) {
logger.error(
appendCopilotLogContext('Failed to persist chat messages', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
}
)
logger.error(`[${tracker.requestId}] Failed to persist chat messages`, {
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
},
},
})
pendingChatStreamHandedOff = true
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
}
@@ -555,19 +443,13 @@ export async function POST(req: NextRequest) {
provider: typeof requestPayload?.provider === 'string' ? requestPayload.provider : undefined,
}
logger.error(
appendCopilotLogContext('Non-streaming response from orchestrator', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
hasContent: !!responseData.content,
contentLength: responseData.content?.length || 0,
model: responseData.model,
provider: responseData.provider,
toolCallsCount: responseData.toolCalls?.length || 0,
}
)
logger.info(`[${tracker.requestId}] Non-streaming response from orchestrator:`, {
hasContent: !!responseData.content,
contentLength: responseData.content?.length || 0,
model: responseData.model,
provider: responseData.provider,
toolCallsCount: responseData.toolCalls?.length || 0,
})
// Save messages if we have a chat
if (currentChat && responseData.content) {
@@ -600,13 +482,8 @@ export async function POST(req: NextRequest) {
// Start title generation in parallel if this is first message (non-streaming)
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
logger.error(
appendCopilotLogContext('Starting title generation for non-streaming response', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
})
)
requestChatTitle({ message, model: selectedModel, provider, messageId: userMessageIdToUse })
logger.info(`[${tracker.requestId}] Starting title generation for non-streaming response`)
requestChatTitle({ message, model: selectedModel, provider })
.then(async (title) => {
if (title) {
await db
@@ -616,22 +493,11 @@ export async function POST(req: NextRequest) {
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
logger.error(
appendCopilotLogContext(`Generated and saved title: ${title}`, {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
})
)
logger.info(`[${tracker.requestId}] Generated and saved title: ${title}`)
}
})
.catch((error) => {
logger.error(
appendCopilotLogContext('Title generation failed', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
error
)
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
})
}
@@ -645,17 +511,11 @@ export async function POST(req: NextRequest) {
.where(eq(copilotChats.id, actualChatId!))
}
logger.error(
appendCopilotLogContext('Returning non-streaming response', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
duration: tracker.getDuration(),
chatId: actualChatId,
responseLength: responseData.content?.length || 0,
}
)
logger.info(`[${tracker.requestId}] Returning non-streaming response`, {
duration: tracker.getDuration(),
chatId: actualChatId,
responseLength: responseData.content?.length || 0,
})
return NextResponse.json({
success: true,
@@ -668,44 +528,24 @@ export async function POST(req: NextRequest) {
},
})
} catch (error) {
if (
actualChatId &&
pendingChatStreamAcquired &&
!pendingChatStreamHandedOff &&
pendingChatStreamID
) {
await releasePendingChatStream(actualChatId, pendingChatStreamID).catch(() => {})
}
const duration = tracker.getDuration()
if (error instanceof z.ZodError) {
logger.error(
appendCopilotLogContext('Validation error', {
requestId: tracker.requestId,
messageId: pendingChatStreamID ?? undefined,
}),
{
duration,
errors: error.errors,
}
)
logger.error(`[${tracker.requestId}] Validation error:`, {
duration,
errors: error.errors,
})
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error(
appendCopilotLogContext('Error handling copilot chat', {
requestId: tracker.requestId,
messageId: pendingChatStreamID ?? undefined,
}),
{
duration,
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
}
)
logger.error(`[${tracker.requestId}] Error handling copilot chat:`, {
duration,
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
@@ -750,16 +590,11 @@ export async function GET(req: NextRequest) {
status: meta?.status || 'unknown',
}
} catch (err) {
logger.warn(
appendCopilotLogContext('Failed to read stream snapshot for chat', {
messageId: chat.conversationId || undefined,
}),
{
chatId,
conversationId: chat.conversationId,
error: err instanceof Error ? err.message : String(err),
}
)
logger.warn('Failed to read stream snapshot for chat', {
chatId,
conversationId: chat.conversationId,
error: err instanceof Error ? err.message : String(err),
})
}
}
@@ -778,11 +613,7 @@ export async function GET(req: NextRequest) {
...(streamSnapshot ? { streamSnapshot } : {}),
}
logger.error(
appendCopilotLogContext(`Retrieved chat ${chatId}`, {
messageId: chat.conversationId || undefined,
})
)
logger.info(`Retrieved chat ${chatId}`)
return NextResponse.json({ success: true, chat: transformedChat })
}
@@ -844,7 +675,7 @@ export async function GET(req: NextRequest) {
chats: transformedChats,
})
} catch (error) {
logger.error('Error fetching copilot chats', error)
logger.error('Error fetching copilot chats:', error)
return createInternalServerErrorResponse('Failed to fetch chats')
}
}

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
getStreamMeta,
readStreamEvents,
@@ -36,24 +35,12 @@ export async function GET(request: NextRequest) {
const toParam = url.searchParams.get('to')
const toEventId = toParam ? Number(toParam) : undefined
logger.error(
appendCopilotLogContext('[Resume] Received resume request', {
messageId: streamId || undefined,
}),
{
streamId: streamId || undefined,
fromEventId,
toEventId,
batchMode,
}
)
if (!streamId) {
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
}
const meta = (await getStreamMeta(streamId)) as StreamMeta | null
logger.error(appendCopilotLogContext('[Resume] Stream lookup', { messageId: streamId }), {
logger.info('[Resume] Stream lookup', {
streamId,
fromEventId,
toEventId,
@@ -72,7 +59,7 @@ export async function GET(request: NextRequest) {
if (batchMode) {
const events = await readStreamEvents(streamId, fromEventId)
const filteredEvents = toEventId ? events.filter((e) => e.eventId <= toEventId) : events
logger.error(appendCopilotLogContext('[Resume] Batch response', { messageId: streamId }), {
logger.info('[Resume] Batch response', {
streamId,
fromEventId,
toEventId,
@@ -124,14 +111,11 @@ export async function GET(request: NextRequest) {
const flushEvents = async () => {
const events = await readStreamEvents(streamId, lastEventId)
if (events.length > 0) {
logger.error(
appendCopilotLogContext('[Resume] Flushing events', { messageId: streamId }),
{
streamId,
fromEventId: lastEventId,
eventCount: events.length,
}
)
logger.info('[Resume] Flushing events', {
streamId,
fromEventId: lastEventId,
eventCount: events.length,
})
}
for (const entry of events) {
lastEventId = entry.eventId
@@ -178,7 +162,7 @@ export async function GET(request: NextRequest) {
}
} catch (error) {
if (!controllerClosed && !request.signal.aborted) {
logger.warn(appendCopilotLogContext('Stream replay failed', { messageId: streamId }), {
logger.warn('Stream replay failed', {
streamId,
error: error instanceof Error ? error.message : String(error),
})

View File

@@ -1,106 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import {
demoRequestSchema,
getDemoRequestRegionLabel,
getDemoRequestUserCountLabel,
} from '@/app/(home)/components/demo-request/consts'
const logger = createLogger('DemoRequestAPI')
const rateLimiter = new RateLimiter()
const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 10,
refillRate: 5,
refillIntervalMs: 60_000,
}
export async function POST(req: NextRequest) {
const requestId = generateRequestId()
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const storageKey = `public:demo-request:${ip}`
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
storageKey,
PUBLIC_ENDPOINT_RATE_LIMIT
)
if (!allowed) {
logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
}
)
}
const body = await req.json()
const validationResult = demoRequestSchema.safeParse(body)
if (!validationResult.success) {
logger.warn(`[${requestId}] Invalid demo request data`, {
errors: validationResult.error.format(),
})
return NextResponse.json(
{ error: 'Invalid request data', details: validationResult.error.format() },
{ status: 400 }
)
}
const { firstName, lastName, companyEmail, phoneNumber, region, userCount, details } =
validationResult.data
logger.info(`[${requestId}] Processing demo request`, {
email: `${companyEmail.substring(0, 3)}***`,
region,
userCount,
})
const emailText = `Demo request submitted
Submitted: ${new Date().toISOString()}
Name: ${firstName} ${lastName}
Email: ${companyEmail}
Phone: ${phoneNumber ?? 'Not provided'}
Region: ${getDemoRequestRegionLabel(region)}
Users: ${getDemoRequestUserCountLabel(userCount)}
Details:
${details}
`
const emailResult = await sendEmail({
to: [`enterprise@${env.EMAIL_DOMAIN || getEmailDomain()}`],
subject: `[DEMO REQUEST] ${firstName} ${lastName}`,
text: emailText,
from: getFromEmailAddress(),
replyTo: companyEmail,
emailType: 'transactional',
})
if (!emailResult.success) {
logger.error(`[${requestId}] Error sending demo request email`, emailResult.message)
return NextResponse.json({ error: 'Failed to submit request' }, { status: 500 })
}
logger.info(`[${requestId}] Demo request email sent successfully`)
return NextResponse.json(
{ success: true, message: 'Thanks! Our team will reach out shortly.' },
{ status: 201 }
)
} catch (error) {
logger.error(`[${requestId}] Error processing demo request`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -7,10 +7,7 @@ import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import {
getFromEmailAddress,
NO_EMAIL_HEADER_CONTROL_CHARS_REGEX,
} from '@/lib/messaging/email/utils'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
const logger = createLogger('IntegrationRequestAPI')
@@ -23,12 +20,7 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
}
const integrationRequestSchema = z.object({
integrationName: z
.string()
.trim()
.min(1, 'Integration name is required')
.max(200)
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
integrationName: z.string().min(1, 'Integration name is required').max(200),
email: z.string().email('A valid email is required'),
useCase: z.string().max(2000).optional(),
})

View File

@@ -7,19 +7,12 @@ import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import {
getFromEmailAddress,
NO_EMAIL_HEADER_CONTROL_CHARS_REGEX,
} from '@/lib/messaging/email/utils'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
const logger = createLogger('HelpAPI')
const helpFormSchema = z.object({
subject: z
.string()
.trim()
.min(1, 'Subject is required')
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(1, 'Message is required'),
type: z.enum(['bug', 'feedback', 'feature_request', 'other']),
})

View File

@@ -1,11 +1,9 @@
import { db } from '@sim/db'
import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, lt } from 'drizzle-orm'
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { sqlIsPaid } from '@/lib/billing/plan-helpers'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/core/config/env'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
@@ -31,13 +29,9 @@ export async function GET(request: NextRequest) {
.from(user)
.leftJoin(
subscription,
and(
eq(user.id, subscription.referenceId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
sqlIsPaid(subscription.plan)
)
sql`${user.id} = ${subscription.referenceId} AND ${subscription.status} = 'active' AND ${subscription.plan} IN ('pro', 'team', 'enterprise')`
)
.where(isNull(subscription.id))
.where(sql`${subscription.id} IS NULL`)
if (freeUsers.length === 0) {
logger.info('No free users found for log cleanup')

View File

@@ -8,11 +8,10 @@ import { getSession } from '@/lib/auth'
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import {
acquirePendingChatStream,
createSSEStream,
SSE_RESPONSE_HEADERS,
waitForPendingChatStream,
} from '@/lib/copilot/chat-streaming'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers'
@@ -88,7 +87,6 @@ const MothershipMessageSchema = z.object({
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
let userMessageIdForLogs: string | undefined
try {
const session = await getSession()
@@ -111,28 +109,6 @@ export async function POST(req: NextRequest) {
} = MothershipMessageSchema.parse(body)
const userMessageId = providedMessageId || crypto.randomUUID()
userMessageIdForLogs = userMessageId
logger.error(
appendCopilotLogContext('Received mothership chat start request', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
{
workspaceId,
chatId,
createNewChat,
hasContexts: Array.isArray(contexts) && contexts.length > 0,
contextsCount: Array.isArray(contexts) ? contexts.length : 0,
hasResourceAttachments:
Array.isArray(resourceAttachments) && resourceAttachments.length > 0,
resourceAttachmentCount: Array.isArray(resourceAttachments)
? resourceAttachments.length
: 0,
hasFileAttachments: Array.isArray(fileAttachments) && fileAttachments.length > 0,
fileAttachmentCount: Array.isArray(fileAttachments) ? fileAttachments.length : 0,
}
)
try {
await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId)
@@ -174,13 +150,7 @@ export async function POST(req: NextRequest) {
actualChatId
)
} catch (e) {
logger.error(
appendCopilotLogContext('Failed to process contexts', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
e
)
logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
}
}
@@ -206,10 +176,7 @@ export async function POST(req: NextRequest) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
logger.error(
appendCopilotLogContext('Failed to resolve resource attachment', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
`[${tracker.requestId}] Failed to resolve resource attachment`,
result.reason
)
}
@@ -286,16 +253,7 @@ export async function POST(req: NextRequest) {
)
if (actualChatId) {
const acquired = await acquirePendingChatStream(actualChatId, userMessageId)
if (!acquired) {
return NextResponse.json(
{
error:
'A response is already in progress for this chat. Wait for it to finish or use Stop.',
},
{ status: 409 }
)
}
await waitForPendingChatStream(actualChatId)
}
const executionId = crypto.randomUUID()
@@ -313,7 +271,6 @@ export async function POST(req: NextRequest) {
titleModel: 'claude-opus-4-6',
requestId: tracker.requestId,
workspaceId,
pendingChatStreamAlreadyRegistered: Boolean(actualChatId),
orchestrateOptions: {
userId: authenticatedUserId,
workspaceId,
@@ -325,7 +282,6 @@ export async function POST(req: NextRequest) {
interactive: true,
onComplete: async (result: OrchestratorResult) => {
if (!actualChatId) return
if (!result.success) return
const assistantMessage: Record<string, unknown> = {
id: crypto.randomUUID(),
@@ -399,16 +355,10 @@ export async function POST(req: NextRequest) {
})
}
} catch (error) {
logger.error(
appendCopilotLogContext('Failed to persist chat messages', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
{
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
}
)
logger.error(`[${tracker.requestId}] Failed to persist chat messages`, {
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
},
},
@@ -423,15 +373,9 @@ export async function POST(req: NextRequest) {
)
}
logger.error(
appendCopilotLogContext('Error handling mothership chat', {
requestId: tracker.requestId,
messageId: userMessageIdForLogs,
}),
{
error: error instanceof Error ? error.message : 'Unknown error',
}
)
logger.error(`[${tracker.requestId}] Error handling mothership chat:`, {
error: error instanceof Error ? error.message : 'Unknown error',
})
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },

View File

@@ -1,217 +0,0 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { taskPubSub } from '@/lib/copilot/task-events'
const logger = createLogger('MothershipChatAPI')
const UpdateChatSchema = z
.object({
title: z.string().trim().min(1).max(200).optional(),
isUnread: z.boolean().optional(),
})
.refine((data) => data.title !== undefined || data.isUnread !== undefined, {
message: 'At least one field must be provided',
})
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const { chatId } = await params
if (!chatId) {
return createBadRequestResponse('chatId is required')
}
const chat = await getAccessibleCopilotChat(chatId, userId)
if (!chat || chat.type !== 'mothership') {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
let streamSnapshot: {
events: Array<{ eventId: number; streamId: string; event: Record<string, unknown> }>
status: string
} | null = null
if (chat.conversationId) {
try {
const [meta, events] = await Promise.all([
getStreamMeta(chat.conversationId),
readStreamEvents(chat.conversationId, 0),
])
streamSnapshot = {
events: events || [],
status: meta?.status || 'unknown',
}
} catch (error) {
logger.warn(
appendCopilotLogContext('Failed to read stream snapshot for mothership chat', {
messageId: chat.conversationId || undefined,
}),
{
chatId,
conversationId: chat.conversationId,
error: error instanceof Error ? error.message : String(error),
}
)
}
}
return NextResponse.json({
success: true,
chat: {
id: chat.id,
title: chat.title,
messages: Array.isArray(chat.messages) ? chat.messages : [],
conversationId: chat.conversationId || null,
resources: Array.isArray(chat.resources) ? chat.resources : [],
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
...(streamSnapshot ? { streamSnapshot } : {}),
},
})
} catch (error) {
logger.error('Error fetching mothership chat:', error)
return createInternalServerErrorResponse('Failed to fetch chat')
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const { chatId } = await params
if (!chatId) {
return createBadRequestResponse('chatId is required')
}
const body = await request.json()
const { title, isUnread } = UpdateChatSchema.parse(body)
const updates: Record<string, unknown> = {}
if (title !== undefined) {
const now = new Date()
updates.title = title
updates.updatedAt = now
if (isUnread === undefined) {
updates.lastSeenAt = now
}
}
if (isUnread !== undefined) {
updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())`
}
const [updatedChat] = await db
.update(copilotChats)
.set(updates)
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
eq(copilotChats.type, 'mothership')
)
)
.returning({
id: copilotChats.id,
workspaceId: copilotChats.workspaceId,
})
if (!updatedChat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
if (title !== undefined && updatedChat.workspaceId) {
taskPubSub?.publishStatusChanged({
workspaceId: updatedChat.workspaceId,
chatId,
type: 'renamed',
})
}
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return createBadRequestResponse('Invalid request data')
}
logger.error('Error updating mothership chat:', error)
return createInternalServerErrorResponse('Failed to update chat')
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const { chatId } = await params
if (!chatId) {
return createBadRequestResponse('chatId is required')
}
const chat = await getAccessibleCopilotChat(chatId, userId)
if (!chat || chat.type !== 'mothership') {
return NextResponse.json({ success: true })
}
const [deletedChat] = await db
.delete(copilotChats)
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
eq(copilotChats.type, 'mothership')
)
)
.returning({
workspaceId: copilotChats.workspaceId,
})
if (!deletedChat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
if (deletedChat.workspaceId) {
taskPubSub?.publishStatusChanged({
workspaceId: deletedChat.workspaceId,
chatId,
type: 'deleted',
})
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting mothership chat:', error)
return createInternalServerErrorResponse('Failed to delete chat')
}
}

View File

@@ -0,0 +1,43 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
const logger = createLogger('MarkTaskReadAPI')
const MarkReadSchema = z.object({
chatId: z.string().min(1),
})
export async function POST(request: NextRequest) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const body = await request.json()
const { chatId } = MarkReadSchema.parse(body)
await db
.update(copilotChats)
.set({ lastSeenAt: sql`GREATEST(${copilotChats.updatedAt}, NOW())` })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return createBadRequestResponse('chatId is required')
}
logger.error('Error marking task as read:', error)
return createInternalServerErrorResponse('Failed to mark task as read')
}
}

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
import {
@@ -36,8 +35,6 @@ const ExecuteRequestSchema = z.object({
* Consumes the Go SSE stream internally and returns a single JSON response.
*/
export async function POST(req: NextRequest) {
let messageId: string | undefined
try {
const auth = await checkInternalAuth(req, { requireWorkflowId: false })
if (!auth.success) {
@@ -51,10 +48,9 @@ export async function POST(req: NextRequest) {
await assertActiveWorkspaceAccess(workspaceId, userId)
const effectiveChatId = chatId || crypto.randomUUID()
messageId = crypto.randomUUID()
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
generateWorkspaceContext(workspaceId, userId),
buildIntegrationToolSchemas(userId, messageId),
buildIntegrationToolSchemas(userId),
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
])
@@ -64,7 +60,7 @@ export async function POST(req: NextRequest) {
userId,
chatId: effectiveChatId,
mode: 'agent',
messageId,
messageId: crypto.randomUUID(),
isHosted: true,
workspaceContext,
...(integrationTools.length > 0 ? { integrationTools } : {}),
@@ -81,7 +77,7 @@ export async function POST(req: NextRequest) {
})
if (!result.success) {
logger.error(appendCopilotLogContext('Mothership execute failed', { messageId }), {
logger.error('Mothership execute failed', {
error: result.error,
errors: result.errors,
})
@@ -120,7 +116,7 @@ export async function POST(req: NextRequest) {
)
}
logger.error(appendCopilotLogContext('Mothership execute error', { messageId }), {
logger.error('Mothership execute error', {
error: error instanceof Error ? error.message : 'Unknown error',
})

View File

@@ -15,7 +15,7 @@ import {
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
@@ -23,9 +23,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -321,7 +320,7 @@ export async function PUT(
.where(
and(
eq(subscriptionTable.referenceId, organizationId),
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
eq(subscriptionTable.status, 'active')
)
)
.limit(1)
@@ -339,8 +338,8 @@ export async function PUT(
.where(
and(
eq(subscriptionTable.referenceId, userId),
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
sqlIsPro(subscriptionTable.plan)
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.plan, 'pro')
)
)
.limit(1)

View File

@@ -1,18 +1,13 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { isTeam } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import {
hasUsableSubscriptionStatus,
USABLE_SUBSCRIPTION_STATUSES,
} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('OrganizationSeatsAPI')
@@ -71,12 +66,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const subscriptionRecord = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.limit(1)
if (subscriptionRecord.length === 0) {
@@ -85,10 +75,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const orgSubscription = subscriptionRecord[0]
if (await isOrganizationBillingBlocked(organizationId)) {
return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
}
// Only team plans support seat changes (not enterprise - those are handled manually)
if (!isTeam(orgSubscription.plan)) {
return NextResponse.json(
@@ -141,7 +127,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
orgSubscription.stripeSubscriptionId
)
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
if (stripeSubscription.status !== 'active') {
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
}

View File

@@ -30,6 +30,7 @@ function toRawGitHubUrl(url: string): string {
throw new Error('Only GitHub URLs are supported')
}
// /owner/repo/blob/branch/path...
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length < 5 || segments[2] !== 'blob') {
throw new Error(
@@ -63,40 +64,42 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: message }, { status: 400 })
}
const response = await fetch(rawUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { Accept: 'text/plain' },
})
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
if (!response.ok) {
logger.warn(`[${requestId}] GitHub fetch failed`, {
status: response.status,
url: rawUrl,
try {
const response = await fetch(rawUrl, {
signal: controller.signal,
headers: { Accept: 'text/plain' },
})
return NextResponse.json(
{ error: `Failed to fetch file (HTTP ${response.status}). Is the repository public?` },
{ status: 502 }
)
if (!response.ok) {
logger.warn(`[${requestId}] GitHub fetch failed`, {
status: response.status,
url: rawUrl,
})
return NextResponse.json(
{ error: `Failed to fetch file (HTTP ${response.status}). Is the repository public?` },
{ status: 502 }
)
}
const content = await response.text()
if (content.length > 100_000) {
return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
}
return NextResponse.json({ content })
} finally {
clearTimeout(timeout)
}
const contentLength = response.headers.get('content-length')
if (contentLength && Number.parseInt(contentLength, 10) > 100_000) {
return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
}
const content = await response.text()
if (content.length > 100_000) {
return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
}
return NextResponse.json({ content })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 })
}
if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
if (error instanceof Error && error.name === 'AbortError') {
logger.warn(`[${requestId}] GitHub fetch timed out`)
return NextResponse.json({ error: 'Request timed out' }, { status: 504 })
}

View File

@@ -13,7 +13,6 @@ const MetadataSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
metadata: z.object({
columnWidths: z.record(z.number().positive()).optional(),
columnOrder: z.array(z.string()).optional(),
}),
})

View File

@@ -5,7 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasPaidSubscription } from '@/lib/billing'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('SubscriptionTransferAPI')
@@ -90,7 +90,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
// Check if org already has an active subscription (prevent duplicates)
if (await hasPaidSubscription(organizationId)) {
if (await hasActiveSubscription(organizationId)) {
return NextResponse.json(
{ error: 'Organization already has an active subscription' },
{ status: 409 }

View File

@@ -26,16 +26,12 @@
import { db } from '@sim/db'
import { organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { addCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
getEffectiveSeats,
} from '@/lib/billing/subscriptions/utils'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -99,7 +95,7 @@ export const POST = withAdminAuth(async (request) => {
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
if (!userSubscription || !isPaid(userSubscription.plan)) {
if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) {
return badRequestResponse(
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
)
@@ -110,7 +106,7 @@ export const POST = withAdminAuth(async (request) => {
const plan = userSubscription.plan
let seats: number | null = null
if (isOrgPlan(plan)) {
if (plan === 'team' || plan === 'enterprise') {
entityType = 'organization'
entityId = userSubscription.referenceId
@@ -127,12 +123,7 @@ export const POST = withAdminAuth(async (request) => {
const [subData] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, entityId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
.limit(1)
seats = getEffectiveSeats(subData)

View File

@@ -19,8 +19,7 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, inArray } from 'drizzle-orm'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { and, count, eq } from 'drizzle-orm'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -59,12 +58,7 @@ export const GET = withAdminAuthParams<RouteParams>(async (request, context) =>
db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.limit(1),
])

View File

@@ -20,15 +20,11 @@
* - durationInMonths: number (required when duration is 'repeating')
* - maxRedemptions: number (optional) — Total redemption cap
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
* - appliesTo: ('pro' | 'team' | 'pro_6000' | 'pro_25000' | 'team_6000' | 'team_25000')[] (optional)
* Restrict coupon to specific plans. Broad values ('pro', 'team') match all tiers.
*/
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type Stripe from 'stripe'
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
import { getPlans } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
@@ -42,17 +38,6 @@ const logger = createLogger('AdminPromoCodes')
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
type Duration = (typeof VALID_DURATIONS)[number]
/** Broad categories match all tiers; specific plan names match exactly. */
const VALID_APPLIES_TO = [
'pro',
'team',
'pro_6000',
'pro_25000',
'team_6000',
'team_25000',
] as const
type AppliesTo = (typeof VALID_APPLIES_TO)[number]
interface PromoCodeResponse {
id: string
code: string
@@ -61,7 +46,6 @@ interface PromoCodeResponse {
percentOff: number
duration: string
durationInMonths: number | null
appliesToProductIds: string[] | null
maxRedemptions: number | null
expiresAt: string | null
active: boolean
@@ -78,7 +62,6 @@ function formatPromoCode(promo: {
percent_off: number | null
duration: string
duration_in_months: number | null
applies_to?: { products: string[] }
}
max_redemptions: number | null
expires_at: number | null
@@ -94,7 +77,6 @@ function formatPromoCode(promo: {
percentOff: promo.coupon.percent_off ?? 0,
duration: promo.coupon.duration,
durationInMonths: promo.coupon.duration_in_months,
appliesToProductIds: promo.coupon.applies_to?.products ?? null,
maxRedemptions: promo.max_redemptions,
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
active: promo.active,
@@ -103,54 +85,6 @@ function formatPromoCode(promo: {
}
}
/**
* Resolve appliesTo values to unique Stripe product IDs.
* Broad categories ('pro', 'team') match all tiers via isPro/isTeam.
* Specific plan names ('pro_6000', 'team_25000') match exactly.
*/
async function resolveProductIds(stripe: Stripe, targets: AppliesTo[]): Promise<string[]> {
const plans = getPlans()
const priceIds: string[] = []
const broadMatchers: Record<string, (name: string) => boolean> = {
pro: isPro,
team: isTeam,
}
for (const plan of plans) {
const matches = targets.some((target) => {
const matcher = broadMatchers[target]
return matcher ? matcher(plan.name) : plan.name === target
})
if (!matches) continue
if (plan.priceId) priceIds.push(plan.priceId)
if (plan.annualDiscountPriceId) priceIds.push(plan.annualDiscountPriceId)
}
const results = await Promise.allSettled(
priceIds.map(async (priceId) => {
const price = await stripe.prices.retrieve(priceId)
return typeof price.product === 'string' ? price.product : price.product.id
})
)
const failures = results.filter((r) => r.status === 'rejected')
if (failures.length > 0) {
logger.error('Failed to resolve all Stripe products for appliesTo', {
failed: failures.length,
total: priceIds.length,
})
throw new Error('Could not resolve all Stripe products for the specified plan categories.')
}
const productIds = new Set<string>()
for (const r of results) {
if (r.status === 'fulfilled') productIds.add(r.value)
}
return [...productIds]
}
export const GET = withAdminAuth(async (request) => {
try {
const stripe = requireStripeClient()
@@ -191,16 +125,7 @@ export const POST = withAdminAuth(async (request) => {
const stripe = requireStripeClient()
const body = await request.json()
const {
name,
percentOff,
code,
duration,
durationInMonths,
maxRedemptions,
expiresAt,
appliesTo,
} = body
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return badRequestResponse('name is required and must be a non-empty string')
@@ -261,36 +186,11 @@ export const POST = withAdminAuth(async (request) => {
}
}
if (appliesTo !== undefined && appliesTo !== null) {
if (!Array.isArray(appliesTo) || appliesTo.length === 0) {
return badRequestResponse('appliesTo must be a non-empty array')
}
const invalid = appliesTo.filter(
(v: unknown) => typeof v !== 'string' || !VALID_APPLIES_TO.includes(v as AppliesTo)
)
if (invalid.length > 0) {
return badRequestResponse(
`appliesTo contains invalid values: ${invalid.join(', ')}. Valid values: ${VALID_APPLIES_TO.join(', ')}`
)
}
}
let appliesToProducts: string[] | undefined
if (appliesTo?.length) {
appliesToProducts = await resolveProductIds(stripe, appliesTo as AppliesTo[])
if (appliesToProducts.length === 0) {
return badRequestResponse(
'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.'
)
}
}
const coupon = await stripe.coupons.create({
name: name.trim(),
percent_off: percentOff,
duration: effectiveDuration,
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
...(appliesToProducts ? { applies_to: { products: appliesToProducts } } : {}),
})
let promoCode
@@ -324,7 +224,6 @@ export const POST = withAdminAuth(async (request) => {
couponId: coupon.id,
percentOff,
duration: effectiveDuration,
...(appliesTo ? { appliesTo } : {}),
})
return singleResponse(formatPromoCode(promoCode))

View File

@@ -24,7 +24,6 @@ import { createLogger } from '@sim/logger'
import { eq, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -155,7 +154,8 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
.limit(1)
const userSubscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan)
const isTeamOrEnterpriseMember =
userSubscription && ['team', 'enterprise'].includes(userSubscription.plan)
const [orgMembership] = await db
.select({ organizationId: member.organizationId })

View File

@@ -0,0 +1,102 @@
import { db } from '@sim/db'
import { agent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { executeAgent } from '@/lib/agents/execute'
import type { AgentConfig } from '@/lib/agents/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AgentAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const ExecuteSchema = z.object({
message: z.string().min(1, 'Message is required'),
conversationId: z.string().optional(),
})
/**
* POST /api/v1/agents/{agentId}
* Execute an agent via the public API. Requires a workspace or personal API key.
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const rateLimit = await checkRateLimit(request, 'agent-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const body = await request.json()
const parsed = ExecuteSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const [agentRow] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt), eq(agent.isDeployed, true)))
.limit(1)
if (!agentRow) {
return NextResponse.json({ error: 'Agent not found or not deployed' }, { status: 404 })
}
const access = await checkWorkspaceAccess(agentRow.workspaceId, userId)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { message, conversationId } = parsed.data
const memoryConversationId = conversationId
? `agent:${agentId}:api:${conversationId}`
: undefined
logger.info(`[${requestId}] V1 API executing agent ${agentId}`, { userId })
const result = await executeAgent({
config: agentRow.config as AgentConfig,
message,
conversationId: memoryConversationId,
agentId,
workspaceId: agentRow.workspaceId,
userId,
isDeployedContext: true,
})
const streamingResult =
result && typeof result === 'object' && 'stream' in result
? (result as { stream: unknown }).stream
: null
if (streamingResult instanceof ReadableStream) {
return new NextResponse(streamingResult, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
return NextResponse.json({ success: true, data: result as Record<string, unknown> })
} catch (error) {
logger.error(`[${requestId}] V1 agent execution failed for ${agentId}`, { error })
return NextResponse.json({ error: 'Execution failed' }, { status: 500 })
}
}

View File

@@ -8,10 +8,8 @@
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('V1AuditLogsAuth')
@@ -59,17 +57,6 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise<Aut
}
}
const billingStatus = await getEffectiveBillingStatus(userId)
if (billingStatus.billingBlocked) {
return {
success: false,
response: NextResponse.json(
{ error: 'Active enterprise subscription required' },
{ status: 403 }
),
}
}
const [orgSub, orgMembers] = await Promise.all([
db
.select({ id: subscription.id })
@@ -78,7 +65,7 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise<Aut
and(
eq(subscription.referenceId, membership.organizationId),
eq(subscription.plan, 'enterprise'),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
eq(subscription.status, 'active')
)
)
.limit(1),

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils'
@@ -33,7 +32,6 @@ const RequestSchema = z.object({
* - The copilot can still operate on any workflow using list_user_workflows
*/
export async function POST(req: NextRequest) {
let messageId: string | undefined
const auth = await authenticateV1Request(req)
if (!auth.authenticated || !auth.userId) {
return NextResponse.json(
@@ -82,25 +80,13 @@ export async function POST(req: NextRequest) {
// Always generate a chatId - required for artifacts system to work with subagents
const chatId = parsed.chatId || crypto.randomUUID()
messageId = crypto.randomUUID()
logger.error(
appendCopilotLogContext('Received headless copilot chat start request', { messageId }),
{
workflowId: resolved.workflowId,
workflowName: parsed.workflowName,
chatId,
mode: transportMode,
autoExecuteTools: parsed.autoExecuteTools,
timeout: parsed.timeout,
}
)
const requestPayload = {
message: parsed.message,
workflowId: resolved.workflowId,
userId: auth.userId,
model: selectedModel,
mode: transportMode,
messageId,
messageId: crypto.randomUUID(),
chatId,
}
@@ -129,7 +115,7 @@ export async function POST(req: NextRequest) {
)
}
logger.error(appendCopilotLogContext('Headless copilot request failed', { messageId }), {
logger.error('Headless copilot request failed', {
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })

View File

@@ -37,7 +37,9 @@ export async function checkRateLimit(
| 'file-detail'
| 'knowledge'
| 'knowledge-detail'
| 'knowledge-search' = 'logs'
| 'knowledge-search'
| 'agents'
| 'agent-detail' = 'logs'
): Promise<RateLimitResult> {
try {
const auth = await authenticateV1Request(request)

View File

@@ -1,11 +1,11 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { userStats, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getBYOKKey } from '@/lib/api-key/byok'
import { getSession } from '@/lib/auth'
import { recordUsage } from '@/lib/billing/core/usage-log'
import { logModelUsage } from '@/lib/billing/core/usage-log'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -134,20 +134,23 @@ async function updateUserStatsForWand(
costToStore = modelCost * costMultiplier
}
await recordUsage({
userId,
entries: [
{
category: 'model',
source: 'wand',
description: modelName,
cost: costToStore,
metadata: { inputTokens: promptTokens, outputTokens: completionTokens },
},
],
additionalStats: {
await db
.update(userStats)
.set({
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
},
totalCost: sql`total_cost + ${costToStore}`,
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
lastActive: new Date(),
})
.where(eq(userStats.userId, userId))
await logModelUsage({
userId,
source: 'wand',
model: modelName,
inputTokens: promptTokens,
outputTokens: completionTokens,
cost: costToStore,
})
await checkAndBillOverageThreshold(userId)
@@ -338,7 +341,7 @@ export async function POST(req: NextRequest) {
let finalUsage: any = null
let usageRecorded = false
const flushUsage = async () => {
const recordUsage = async () => {
if (usageRecorded || !finalUsage) {
return
}
@@ -357,7 +360,7 @@ export async function POST(req: NextRequest) {
if (done) {
logger.info(`[${requestId}] Stream completed. Total chunks: ${chunkCount}`)
await flushUsage()
await recordUsage()
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`))
controller.close()
break
@@ -387,7 +390,7 @@ export async function POST(req: NextRequest) {
if (data === '[DONE]') {
logger.info(`[${requestId}] Received [DONE] signal`)
await flushUsage()
await recordUsage()
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)
@@ -465,7 +468,7 @@ export async function POST(req: NextRequest) {
})
try {
await flushUsage()
await recordUsage()
} catch (usageError) {
logger.warn(`[${requestId}] Failed to record usage after stream error`, usageError)
}

View File

@@ -3,17 +3,9 @@
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Mic, MicOff, Phone } from 'lucide-react'
import dynamic from 'next/dynamic'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/core/utils/cn'
const ParticlesVisualization = dynamic(
() =>
import('@/app/chat/components/voice-interface/components/particles').then(
(mod) => mod.ParticlesVisualization
),
{ ssr: false }
)
import { ParticlesVisualization } from '@/app/chat/components/voice-interface/components/particles'
const logger = createLogger('VoiceInterface')

View File

@@ -3,7 +3,11 @@ import Script from 'next/script'
import { PublicEnvScript } from 'next-runtime-env'
import { BrandedLayout } from '@/components/branded-layout'
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling'
import {
generateBrandedMetadata,
generateStructuredData,
generateThemeCSS,
} from '@/ee/whitelabeling'
import '@/app/_styles/globals.css'
import { OneDollarStats } from '@/components/analytics/onedollarstats'
import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags'
@@ -17,6 +21,8 @@ import { season } from '@/app/_styles/fonts/season/season'
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
@@ -26,6 +32,7 @@ export const viewport: Viewport = {
export const metadata: Metadata = generateBrandedMetadata()
export default function RootLayout({ children }: { children: React.ReactNode }) {
const structuredData = generateStructuredData()
const themeCSS = generateThemeCSS()
return (
@@ -69,6 +76,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
strategy='lazyOnload'
/>
)}
{/* Structured Data for SEO */}
<script
type='application/ld+json'
dangerouslySetInnerHTML={{
__html: JSON.stringify(structuredData),
}}
/>
{/*
Workspace layout dimensions: set CSS vars before hydration to avoid layout jump.

View File

@@ -1,7 +1,6 @@
import type { MetadataRoute } from 'next'
import { getAllPostMeta } from '@/lib/blog/registry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import integrations from '@/app/(landing)/integrations/data/integrations.json'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
@@ -25,10 +24,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// url: `${baseUrl}/templates`,
// lastModified: now,
// },
{
url: `${baseUrl}/integrations`,
lastModified: now,
},
{
url: `${baseUrl}/changelog`,
lastModified: now,
@@ -49,10 +44,5 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: new Date(p.updated ?? p.date),
}))
const integrationPages: MetadataRoute.Sitemap = integrations.map((i) => ({
url: `${baseUrl}/integrations/${i.slug}`,
lastModified: now,
}))
return [...staticPages, ...blogPages, ...integrationPages]
return [...staticPages, ...blogPages]
}

View File

@@ -1,7 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Star, User } from 'lucide-react'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
@@ -282,14 +281,9 @@ function TemplateCardInner({
<div className='mt-[10px] flex items-center justify-between'>
<div className='flex min-w-0 items-center gap-[8px]'>
{authorImageUrl ? (
<Image
src={authorImageUrl}
alt={author}
width={20}
height={20}
className='flex-shrink-0 rounded-full object-cover'
unoptimized
/>
<div className='h-[20px] w-[20px] flex-shrink-0 overflow-hidden rounded-full'>
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
</div>
) : (
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-7)]'>
<User className='h-[12px] w-[12px] text-[var(--text-muted)]' />

View File

@@ -0,0 +1,226 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { Pencil, Trash } from '@/components/emcn/icons'
import { AgentIcon } from '@/components/icons'
import type { AgentConfig } from '@/lib/agents/types'
import { AgentConfigPanel } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-config/agent-config-panel'
import { AgentTestPanel } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-test-panel'
import { DeployModal } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/deploy-modal'
import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components'
import { ResourceHeader } from '@/app/workspace/[workspaceId]/components'
import { useAgent, useDeleteAgent, useUpdateAgent } from '@/hooks/queries/agents'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('AgentDetail')
const AUTOSAVE_DELAY_MS = 1000
interface AgentDetailProps {
agentId: string
workspaceId: string
}
export function AgentDetail({ agentId, workspaceId }: AgentDetailProps) {
const router = useRouter()
const { data: agent, isLoading } = useAgent(agentId)
const { mutateAsync: updateAgent } = useUpdateAgent()
const { mutateAsync: deleteAgent } = useDeleteAgent()
const [localConfig, setLocalConfig] = useState<AgentConfig>({})
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
const [isDeleting, setIsDeleting] = useState(false)
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isInitializedRef = useRef(false)
// SubBlockStore.setValue reads activeWorkflowId from the workflow registry and no-ops when null.
// Set the agentId as the active "workflow" so tool sub-block params (credentials, etc.) persist.
useEffect(() => {
useWorkflowRegistry.setState({ activeWorkflowId: agentId })
return () => {
if (useWorkflowRegistry.getState().activeWorkflowId === agentId) {
useWorkflowRegistry.setState({ activeWorkflowId: null })
}
}
}, [agentId])
useEffect(() => {
if (agent && !isInitializedRef.current) {
setLocalConfig(agent.config ?? {})
isInitializedRef.current = true
}
}, [agent])
const agentRename = useInlineRename({
onSave: (id, name) => updateAgent({ agentId: id, name }),
})
const scheduleSave = useCallback(
(updatedConfig: AgentConfig) => {
setSaveStatus('unsaved')
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
saveTimerRef.current = null
setSaveStatus('saving')
try {
await updateAgent({ agentId, config: updatedConfig })
setSaveStatus('saved')
} catch (error) {
logger.error('Failed to auto-save agent', { error })
setSaveStatus('unsaved')
}
}, AUTOSAVE_DELAY_MS)
},
[agentId, updateAgent]
)
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
}
}, [])
const handleConfigChange = useCallback(
(patch: Partial<AgentConfig>) => {
setLocalConfig((prev) => {
const next = { ...prev, ...patch }
scheduleSave(next)
return next
})
},
[scheduleSave]
)
const handleDelete = useCallback(async () => {
setIsDeleting(true)
try {
await deleteAgent({ agentId })
router.push(`/workspace/${workspaceId}/agents`)
} catch (error) {
logger.error('Failed to delete agent', { error })
setIsDeleting(false)
}
}, [agentId, deleteAgent, router, workspaceId])
const currentName = agent?.name ?? ''
const breadcrumbs = useMemo<BreadcrumbItem[]>(
() => [
{ label: 'Agents', onClick: () => router.push(`/workspace/${workspaceId}/agents`) },
{
label: currentName || '…',
editing:
agentRename.editingId === agentId
? {
isEditing: true,
value: agentRename.editValue,
onChange: agentRename.setEditValue,
onSubmit: agentRename.submitRename,
onCancel: agentRename.cancelRename,
}
: undefined,
dropdownItems: [
{
label: 'Rename',
icon: Pencil,
onClick: () => agentRename.startRename(agentId, currentName),
},
{
label: 'Delete',
icon: Trash,
onClick: handleDelete,
disabled: isDeleting,
},
],
},
],
[
agentId,
currentName,
agentRename.editingId,
agentRename.editValue,
agentRename.setEditValue,
agentRename.submitRename,
agentRename.cancelRename,
agentRename.startRename,
handleDelete,
isDeleting,
router,
workspaceId,
]
)
const saveStatusLabel =
saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : undefined
if (isLoading) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<ResourceHeader icon={AgentIcon} breadcrumbs={[{ label: 'Agents' }, { label: '…' }]} />
<div className='flex flex-1 items-center justify-center'>
<span className='text-[14px] text-[var(--text-muted)]'>Loading</span>
</div>
</div>
)
}
if (!agent) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<ResourceHeader
icon={AgentIcon}
breadcrumbs={[
{ label: 'Agents', onClick: () => router.push(`/workspace/${workspaceId}/agents`) },
{ label: 'Not found' },
]}
/>
<div className='flex flex-1 items-center justify-center'>
<span className='text-[14px] text-[var(--text-muted)]'>Agent not found.</span>
</div>
</div>
)
}
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<ResourceHeader
icon={AgentIcon}
breadcrumbs={breadcrumbs}
actions={[
...(saveStatusLabel
? [{ label: saveStatusLabel, onClick: () => {}, disabled: true }]
: []),
{ label: 'Deploy', onClick: () => setIsDeployModalOpen(true) },
]}
/>
<div className='flex min-h-0 flex-1 overflow-hidden'>
<div className='flex w-[400px] min-w-[320px] flex-shrink-0 flex-col overflow-y-auto border-[var(--border-1)] border-r'>
<AgentConfigPanel
config={localConfig}
agentId={agentId}
workspaceId={workspaceId}
onConfigChange={handleConfigChange}
/>
</div>
<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
<AgentTestPanel agentId={agentId} />
</div>
</div>
<DeployModal
agentId={agentId}
workspaceId={workspaceId}
isDeployed={agent.isDeployed}
open={isDeployModalOpen}
onOpenChange={setIsDeployModalOpen}
/>
</div>
)
}

View File

@@ -0,0 +1,789 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import {
Button,
Combobox,
type ComboboxOption,
type ComboboxOptionGroup,
Input,
Label,
Textarea,
} from '@/components/emcn'
import { ArrowUp, ChevronDown, Plus, X } from '@/components/emcn/icons'
import { AgentSkillsIcon } from '@/components/icons'
import type { AgentConfig, SkillInput } from '@/lib/agents/types'
import { getScopesForService } from '@/lib/oauth/utils'
import { AgentToolInput } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-config/agent-tool-input'
import {
type AgentWandState,
useAgentWand,
} from '@/app/workspace/[workspaceId]/agents/[agentId]/hooks/use-agent-wand'
import { SkillModal } from '@/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal'
import {
checkEnvVarTrigger,
EnvVarDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector'
import {
getModelOptions,
RESPONSE_FORMAT_WAND_CONFIG,
shouldRequireApiKeyForModel,
} from '@/blocks/utils'
import { type SkillDefinition, useSkills } from '@/hooks/queries/skills'
import {
getMaxTemperature,
getReasoningEffortValuesForModel,
getThinkingLevelsForModel,
getVerbosityValuesForModel,
MODELS_WITH_DEEP_RESEARCH,
MODELS_WITH_REASONING_EFFORT,
MODELS_WITH_THINKING,
MODELS_WITH_VERBOSITY,
MODELS_WITHOUT_MEMORY,
providers,
supportsTemperature,
} from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
const MEMORY_OPTIONS: ComboboxOption[] = [
{ value: 'none', label: 'None' },
{ value: 'conversation', label: 'Conversation history' },
{ value: 'sliding_window', label: 'Sliding window (messages)' },
{ value: 'sliding_window_tokens', label: 'Sliding window (tokens)' },
]
const AZURE_MODELS = [...providers['azure-openai'].models, ...providers['azure-anthropic'].models]
const DASHED_DIVIDER_STYLE = {
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
} as const
interface AgentConfigPanelProps {
config: AgentConfig
agentId: string
workspaceId: string
onConfigChange: (patch: Partial<AgentConfig>) => void
}
const SYSTEM_PROMPT_WAND_PROMPT = `You are an expert at creating professional, comprehensive LLM agent system prompts. Generate or modify a system prompt based on the user's request.
Current system prompt: {context}
RULES:
1. Generate ONLY the system prompt text — no JSON, no explanations, no markdown fences
2. Start with a clear role definition ("You are...")
3. Include specific methodology, response format requirements, and edge case handling
4. If editing, preserve structure unless asked to change it
5. Be detailed and professional`
function Divider() {
return (
<div className='px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
</div>
)
}
function FieldLabelRow({ label }: { label: string }) {
return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='font-medium text-[13px]'>{label}</Label>
</div>
)
}
export function AgentConfigPanel({
config,
agentId,
workspaceId,
onConfigChange,
}: AgentConfigPanelProps) {
const storeProviders = useProvidersStore((s) => s.providers)
const model = config.model ?? ''
const [showAdvanced, setShowAdvanced] = useState(false)
const derived = useMemo(() => {
const reasoningValues = model ? getReasoningEffortValuesForModel(model) : null
const thinkingValues = model ? getThinkingLevelsForModel(model) : null
const verbosityValues = model ? getVerbosityValuesForModel(model) : null
const toOptions = (vals: string[]): ComboboxOption[] =>
vals.map((v) => ({ value: v, label: v }))
const isDeepResearch = MODELS_WITH_DEEP_RESEARCH.includes(model)
const showTemperature = Boolean(model) && supportsTemperature(model) && !isDeepResearch
return {
isVertexModel: providers.vertex.models.includes(model),
isAzureModel: AZURE_MODELS.includes(model),
isBedrockModel: providers.bedrock.models.includes(model),
showApiKey: shouldRequireApiKeyForModel(model),
isDeepResearch,
showMemory: !MODELS_WITHOUT_MEMORY.includes(model),
showReasoningEffort: MODELS_WITH_REASONING_EFFORT.includes(model),
showThinking: MODELS_WITH_THINKING.includes(model),
showVerbosity: MODELS_WITH_VERBOSITY.includes(model),
showTemperature,
maxTemperature: (model && getMaxTemperature(model)) ?? 1,
reasoningEffortOptions: toOptions(reasoningValues ?? ['auto', 'low', 'medium', 'high']),
thinkingLevelOptions: toOptions(
thinkingValues ? ['none', ...thinkingValues] : ['none', 'low', 'high']
),
verbosityOptions: toOptions(verbosityValues ?? ['auto', 'low', 'medium', 'high']),
}
}, [model])
const hasAdvancedFields =
derived.showReasoningEffort ||
derived.showThinking ||
derived.showVerbosity ||
derived.showTemperature ||
!derived.isDeepResearch
const modelOptions: ComboboxOption[] = useMemo(
() =>
getModelOptions().map((opt) => ({
label: opt.label,
value: opt.id,
...(opt.icon && { icon: opt.icon }),
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[storeProviders]
)
const systemPrompt = config.messages?.find((m) => m.role === 'system')?.content ?? ''
const handleSystemPromptChange = useCallback(
(value: string) => {
const filtered = (config.messages ?? []).filter((m) => m.role !== 'system')
const messages = value.trim()
? [{ role: 'system' as const, content: value }, ...filtered]
: filtered
onConfigChange({ messages })
},
[config.messages, onConfigChange]
)
const systemPromptWand = useAgentWand({
systemPrompt: SYSTEM_PROMPT_WAND_PROMPT,
maintainHistory: true,
currentValue: systemPrompt,
onGeneratedContent: handleSystemPromptChange,
})
const responseFormatWand = useAgentWand({
systemPrompt: RESPONSE_FORMAT_WAND_CONFIG.prompt,
generationType: RESPONSE_FORMAT_WAND_CONFIG.generationType,
maintainHistory: RESPONSE_FORMAT_WAND_CONFIG.maintainHistory,
currentValue: config.responseFormat ?? '',
onGeneratedContent: (content) => onConfigChange({ responseFormat: content }),
})
const [apiKeyFocused, setApiKeyFocused] = useState(false)
const [apiKeyEnvDropdown, setApiKeyEnvDropdown] = useState({ visible: false, searchTerm: '' })
const apiKeyInputRef = useRef<HTMLInputElement>(null)
return (
<div className='px-[8px] pt-[12px] pb-[8px]'>
{/* System prompt */}
<div className='flex flex-col gap-[10px]'>
<WandLabelRow label='System prompt' wand={systemPromptWand} />
<Textarea
placeholder='You are a helpful assistant…'
value={systemPrompt}
onChange={(e) => handleSystemPromptChange(e.target.value)}
rows={6}
className='resize-none text-[13px]'
/>
</div>
<Divider />
{/* Model */}
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Model' />
<Combobox
options={modelOptions}
value={config.model ?? ''}
onChange={(v) => onConfigChange({ model: v })}
placeholder='claude-sonnet-4-6'
editable
searchable
searchPlaceholder='Search models…'
emptyMessage='No models found'
maxHeight={240}
inputProps={{ autoComplete: 'off' }}
/>
</div>
<Divider />
{/* Vertex AI */}
{derived.isVertexModel && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Google Cloud account' />
<ToolCredentialSelector
value={config.vertexCredential ?? ''}
onChange={(v) => onConfigChange({ vertexCredential: v || undefined })}
provider='vertex-ai'
serviceId='vertex-ai'
requiredScopes={getScopesForService('vertex-ai')}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Google Cloud project' />
<Input
value={config.vertexProject ?? ''}
placeholder='your-gcp-project-id'
autoComplete='off'
onChange={(e) => onConfigChange({ vertexProject: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Google Cloud location' />
<Input
value={config.vertexLocation ?? ''}
placeholder='us-central1'
autoComplete='off'
onChange={(e) => onConfigChange({ vertexLocation: e.target.value || undefined })}
/>
</div>
<Divider />
</>
)}
{/* Azure */}
{derived.isAzureModel && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Azure endpoint' />
<Input
value={config.azureEndpoint ?? ''}
placeholder='https://your-resource.services.ai.azure.com'
autoComplete='off'
onChange={(e) => onConfigChange({ azureEndpoint: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Azure API version' />
<Input
value={config.azureApiVersion ?? ''}
placeholder='2024-12-01-preview'
autoComplete='off'
onChange={(e) => onConfigChange({ azureApiVersion: e.target.value || undefined })}
/>
</div>
<Divider />
</>
)}
{/* AWS Bedrock */}
{derived.isBedrockModel && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='AWS access key ID' />
<Input
value={config.bedrockAccessKeyId ?? ''}
placeholder='Enter your AWS Access Key ID'
autoComplete='off'
onChange={(e) => onConfigChange({ bedrockAccessKeyId: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='AWS secret access key' />
<Input
value={config.bedrockSecretKey ?? ''}
placeholder='Enter your AWS Secret Access Key'
autoComplete='off'
onChange={(e) => onConfigChange({ bedrockSecretKey: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='AWS region' />
<Input
value={config.bedrockRegion ?? ''}
placeholder='us-east-1'
autoComplete='off'
onChange={(e) => onConfigChange({ bedrockRegion: e.target.value || undefined })}
/>
</div>
<Divider />
</>
)}
{/* API key */}
{derived.showApiKey && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='API key' />
<div className='relative'>
<Input
ref={apiKeyInputRef}
type='text'
value={
apiKeyFocused ? (config.apiKey ?? '') : '•'.repeat(config.apiKey?.length ?? 0)
}
placeholder='Enter your API key'
autoComplete='off'
data-lpignore='true'
data-form-type='other'
className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
onFocus={() => setApiKeyFocused(true)}
onBlur={() => setApiKeyFocused(false)}
onChange={(e) => {
const val = e.target.value
onConfigChange({ apiKey: val || undefined })
const cursor = e.target.selectionStart ?? val.length
const { show, searchTerm } = checkEnvVarTrigger(val, cursor)
setApiKeyEnvDropdown({ visible: show, searchTerm })
}}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto px-[8px] py-[6px] pr-3 font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
<div className='min-w-fit whitespace-pre'>
{apiKeyFocused
? formatDisplayText(config.apiKey ?? '')
: '•'.repeat(config.apiKey?.length ?? 0)}
</div>
</div>
<EnvVarDropdown
visible={apiKeyEnvDropdown.visible}
searchTerm={apiKeyEnvDropdown.searchTerm}
inputValue={config.apiKey ?? ''}
cursorPosition={
apiKeyInputRef.current?.selectionStart ?? config.apiKey?.length ?? 0
}
workspaceId={workspaceId}
inputRef={apiKeyInputRef as React.RefObject<HTMLInputElement>}
onSelect={(newValue) => {
onConfigChange({ apiKey: newValue || undefined })
setApiKeyEnvDropdown({ visible: false, searchTerm: '' })
}}
onClose={() => setApiKeyEnvDropdown({ visible: false, searchTerm: '' })}
/>
</div>
</div>
<Divider />
</>
)}
{/* Tools */}
{!derived.isDeepResearch && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Tools' />
<AgentToolInput
workspaceId={workspaceId}
selectedTools={config.tools ?? []}
model={config.model}
onChange={(tools) => onConfigChange({ tools })}
/>
</div>
<Divider />
</>
)}
{/* Skills */}
{!derived.isDeepResearch && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Skills' />
<SkillsInput
workspaceId={workspaceId}
selectedSkills={config.skills ?? []}
onChange={(skills) => onConfigChange({ skills })}
/>
</div>
<Divider />
</>
)}
{/* Memory */}
{derived.showMemory && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Memory' />
<Combobox
options={MEMORY_OPTIONS}
value={config.memoryType ?? 'none'}
onChange={(v) => onConfigChange({ memoryType: v as AgentConfig['memoryType'] })}
/>
</div>
{config.memoryType && config.memoryType !== 'none' && (
<>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Conversation ID' />
<Input
value={config.conversationId ?? ''}
placeholder='e.g., user-123, session-abc'
autoComplete='off'
onChange={(e) => onConfigChange({ conversationId: e.target.value || undefined })}
/>
</div>
</>
)}
{config.memoryType === 'sliding_window' && (
<>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Window size (messages)' />
<Input
value={config.slidingWindowSize ?? ''}
placeholder='20'
autoComplete='off'
onChange={(e) =>
onConfigChange({ slidingWindowSize: e.target.value || undefined })
}
/>
</div>
</>
)}
{config.memoryType === 'sliding_window_tokens' && (
<>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Window size (tokens)' />
<Input
value={config.slidingWindowTokens ?? ''}
placeholder='4000'
autoComplete='off'
onChange={(e) =>
onConfigChange({ slidingWindowTokens: e.target.value || undefined })
}
/>
</div>
</>
)}
<Divider />
</>
)}
{/* Response format */}
{!derived.isDeepResearch && (
<div className='flex flex-col gap-[10px]'>
<WandLabelRow label='Response format' wand={responseFormatWand} />
<Textarea
placeholder={
'{\n "name": "my_schema",\n "strict": true,\n "schema": { "type": "object", "properties": {} }\n}'
}
value={config.responseFormat ?? ''}
onChange={(e) => onConfigChange({ responseFormat: e.target.value || undefined })}
rows={4}
className='resize-none font-mono text-[12px]'
/>
</div>
)}
{/* Previous interaction ID (deep research) */}
{derived.isDeepResearch && (
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Previous interaction ID' />
<Input
value={config.previousInteractionId ?? ''}
placeholder='e.g., {{agent_1.interactionId}}'
autoComplete='off'
onChange={(e) => onConfigChange({ previousInteractionId: e.target.value || undefined })}
/>
</div>
)}
{/* Advanced fields toggle */}
{hasAdvancedFields && (
<>
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<button
type='button'
onClick={() => setShowAdvanced((v) => !v)}
className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
>
{showAdvanced ? 'Hide additional fields' : 'Show additional fields'}
<ChevronDown
className={`h-[14px] w-[14px] transition-transform duration-200 ${showAdvanced ? 'rotate-180' : ''}`}
/>
</button>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
</div>
{showAdvanced && (
<>
{derived.showReasoningEffort && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Reasoning effort' />
<Combobox
options={derived.reasoningEffortOptions}
value={config.reasoningEffort ?? 'auto'}
onChange={(v) => onConfigChange({ reasoningEffort: v })}
/>
</div>
<Divider />
</>
)}
{derived.showThinking && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Thinking level' />
<Combobox
options={derived.thinkingLevelOptions}
value={config.thinkingLevel ?? 'none'}
onChange={(v) => onConfigChange({ thinkingLevel: v })}
/>
</div>
<Divider />
</>
)}
{derived.showVerbosity && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Verbosity' />
<Combobox
options={derived.verbosityOptions}
value={config.verbosity ?? 'auto'}
onChange={(v) => onConfigChange({ verbosity: v })}
/>
</div>
<Divider />
</>
)}
{derived.showTemperature && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label={`Temperature (0${derived.maxTemperature})`} />
<Input
type='number'
value={config.temperature ?? ''}
min={0}
max={derived.maxTemperature}
step={0.1}
placeholder='1.0'
onChange={(e) => {
const v = Number.parseFloat(e.target.value)
onConfigChange({ temperature: Number.isNaN(v) ? undefined : v })
}}
/>
</div>
<Divider />
</>
)}
{!derived.isDeepResearch && (
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Max tokens' />
<Input
type='number'
value={config.maxTokens ?? ''}
min={1}
max={200000}
placeholder='default'
onChange={(e) => {
const v = Number.parseFloat(e.target.value)
onConfigChange({ maxTokens: Number.isNaN(v) ? undefined : v })
}}
/>
</div>
)}
</>
)}
</>
)}
</div>
)
}
interface WandLabelRowProps {
label: string
wand: AgentWandState
}
function WandLabelRow({ label, wand }: WandLabelRowProps) {
return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='font-medium text-[13px]'>{label}</Label>
<div className='flex min-w-0 flex-1 items-center justify-end'>
{!wand.isSearchActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={wand.onSearchClick}
>
Generate
</Button>
) : (
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
<Input
ref={wand.searchInputRef}
value={wand.isStreaming ? 'Generating...' : wand.searchQuery}
onChange={(e) => wand.onSearchChange(e.target.value)}
onBlur={(e) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
if (relatedTarget?.closest('button')) return
wand.onSearchBlur()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && wand.searchQuery.trim() && !wand.isStreaming) {
wand.onSearchSubmit()
} else if (e.key === 'Escape') {
wand.onSearchCancel()
}
}}
disabled={wand.isStreaming}
className='h-5 min-w-[80px] flex-1 text-[11px]'
placeholder='Generate with AI...'
/>
<Button
variant='primary'
disabled={!wand.searchQuery.trim() || wand.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
wand.onSearchSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
)}
</div>
</div>
)
}
interface SkillsInputProps {
workspaceId: string
selectedSkills: SkillInput[]
onChange: (skills: SkillInput[]) => void
}
function SkillsInput({ workspaceId, selectedSkills, onChange }: SkillsInputProps) {
const { data: workspaceSkills = [] } = useSkills(workspaceId)
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingSkill, setEditingSkill] = useState<SkillDefinition | null>(null)
const selectedIds = useMemo(() => new Set(selectedSkills.map((s) => s.skillId)), [selectedSkills])
const skillGroups = useMemo((): ComboboxOptionGroup[] => {
const available = workspaceSkills.filter((s) => !selectedIds.has(s.id))
const groups: ComboboxOptionGroup[] = [
{
items: [
{
label: 'Create skill',
value: 'action-create-skill',
icon: Plus,
onSelect: () => setShowCreateModal(true),
keepOpen: false,
},
],
},
]
if (available.length > 0) {
groups.push({
section: 'Skills',
items: available.map((s) => ({
label: s.name,
value: `skill-${s.id}`,
icon: AgentSkillsIcon,
onSelect: () => onChange([...selectedSkills, { skillId: s.id, name: s.name }]),
})),
})
}
return groups
}, [workspaceSkills, selectedIds, selectedSkills, onChange])
const handleRemove = useCallback(
(skillId: string) => onChange(selectedSkills.filter((s) => s.skillId !== skillId)),
[selectedSkills, onChange]
)
const resolveSkillName = useCallback(
(stored: SkillInput): string => {
const found = workspaceSkills.find((s) => s.id === stored.skillId)
return found?.name ?? stored.name ?? stored.skillId
},
[workspaceSkills]
)
return (
<>
<div className='w-full space-y-[8px]'>
<Combobox
options={[]}
groups={skillGroups}
placeholder='Add skill…'
searchable
searchPlaceholder='Search skills…'
maxHeight={240}
emptyMessage='No skills found'
/>
{selectedSkills.map((stored) => {
const fullSkill = workspaceSkills.find((s) => s.id === stored.skillId)
return (
<div
key={stored.skillId}
className='group relative flex flex-col overflow-hidden rounded-[4px] border border-[var(--border-1)] transition-all duration-200 ease-in-out'
>
<div
className='flex cursor-pointer items-center justify-between gap-[8px] rounded-[4px] bg-[var(--surface-4)] px-[8px] py-[6.5px]'
onClick={() => fullSkill && setEditingSkill(fullSkill)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--surface-4)]'>
<AgentSkillsIcon className='h-[10px] w-[10px] text-[#333]' />
</div>
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{resolveSkillName(stored)}
</span>
</div>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
handleRemove(stored.skillId)
}}
className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Remove skill'
>
<X className='h-[13px] w-[13px]' />
</button>
</div>
</div>
)
})}
</div>
<SkillModal
open={showCreateModal || !!editingSkill}
onOpenChange={(open) => {
if (!open) {
setShowCreateModal(false)
setEditingSkill(null)
}
}}
onSave={() => {
setShowCreateModal(false)
setEditingSkill(null)
}}
initialValues={editingSkill ?? undefined}
/>
</>
)
}

View File

@@ -0,0 +1,739 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useParams } from 'next/navigation'
import { ReactFlowProvider } from 'reactflow'
import {
Combobox,
type ComboboxOptionGroup,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
} from '@/components/emcn'
import { Plus, Wrench, X } from '@/components/emcn/icons'
import { McpIcon, WorkflowIcon } from '@/components/icons'
import type { ToolInput } from '@/lib/agents/types'
import { cn } from '@/lib/core/utils/cn'
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
import {
type CustomTool,
CustomToolModal,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer'
import { getAllBlocks } from '@/blocks'
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
import { type McpToolForUI, useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { useCustomTools } from '@/hooks/queries/custom-tools'
import { useAllowedMcpDomains, useCreateMcpServer, useMcpServers } from '@/hooks/queries/mcp'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
import { getSubBlocksForToolInput, getToolParametersConfig } from '@/tools/params'
/** Returns true if a block type has more than one tool operation. */
function hasMultipleOperations(blockType: string): boolean {
const block = getAllBlocks().find((b) => b.type === blockType)
return (block?.tools?.access?.length || 0) > 1
}
/** Returns the available operation options for a multi-operation block. */
function getOperationOptions(blockType: string): { label: string; id: string }[] {
const block = getAllBlocks().find((b) => b.type === blockType)
if (!block?.tools?.access) return []
const opSubBlock = block.subBlocks.find((sb) => sb.id === 'operation')
if (opSubBlock?.type === 'dropdown' && Array.isArray(opSubBlock.options)) {
return opSubBlock.options as { label: string; id: string }[]
}
return block.tools.access.map((toolId) => {
const params = getToolParametersConfig(toolId)
return { id: toolId, label: params?.toolConfig?.name || toolId }
})
}
/** Returns the concrete toolId for a given block type and optional operation. */
function getToolIdForOperation(blockType: string, operation?: string): string | undefined {
const block = getAllBlocks().find((b) => b.type === blockType)
if (!block?.tools?.access) return undefined
if (block.tools.access.length === 1) return block.tools.access[0]
if (operation && block.tools?.config?.tool) {
try {
return block.tools.config.tool({ operation })
} catch {}
}
if (operation && block.tools.access.includes(operation)) return operation
return block.tools.access[0]
}
interface AgentToolInputProps {
workspaceId: string
selectedTools: ToolInput[]
model?: string
onChange: (tools: ToolInput[]) => void
}
export function AgentToolInput({
workspaceId,
selectedTools,
model,
onChange,
}: AgentToolInputProps) {
const params = useParams()
const agentId = (params?.agentId as string) || 'agent'
const [activeMcpServerId, setActiveMcpServerId] = useState<string | null>(null)
const [showCustomToolModal, setShowCustomToolModal] = useState(false)
const [showMcpModal, setShowMcpModal] = useState(false)
const [usageControlIndex, setUsageControlIndex] = useState<number | null>(null)
const { data: customTools = [] } = useCustomTools(workspaceId)
const { mcpTools } = useMcpTools(workspaceId)
const { data: servers = [] } = useMcpServers(workspaceId)
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
const createMcpServer = useCreateMcpServer()
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const toolBlocks = useMemo(
() =>
getAllBlocks().filter(
(block) =>
!block.hideFromToolbar &&
(block.category === 'tools' ||
block.type === 'api' ||
block.type === 'webhook_request' ||
block.type === 'knowledge' ||
block.type === 'function' ||
block.type === 'table') &&
block.type !== 'evaluator' &&
block.type !== 'mcp' &&
block.type !== 'file'
),
[]
)
const handleSelectBlock = useCallback(
(block: ReturnType<typeof getAllBlocks>[number]) => {
const hasOps = hasMultipleOperations(block.type)
const ops = hasOps ? getOperationOptions(block.type) : []
const defaultOp = ops.length > 0 ? ops[0].id : undefined
const toolId = getToolIdForOperation(block.type, defaultOp) || ''
const newTool: ToolInput = {
type: block.type,
title: block.name,
toolId,
params: {},
operation: defaultOp,
isExpanded: true,
usageControl: 'auto',
}
onChange([...selectedTools.map((t) => ({ ...t, isExpanded: false })), newTool])
},
[selectedTools, onChange]
)
const handleSelectCustomTool = useCallback(
(tool: { id: string; title: string }) => {
const newTool: ToolInput = {
type: 'custom-tool',
title: tool.title,
customToolId: tool.id,
usageControl: 'auto',
}
onChange([...selectedTools, newTool])
},
[selectedTools, onChange]
)
const handleSelectMcpTool = useCallback(
(mcpTool: McpToolForUI) => {
const newTool: ToolInput = {
type: 'mcp',
title: mcpTool.name,
toolId: mcpTool.id,
params: {
serverId: mcpTool.serverId,
toolName: mcpTool.name,
serverName: mcpTool.serverName,
},
schema: mcpTool.inputSchema,
usageControl: 'auto',
}
onChange([...selectedTools, newTool])
},
[selectedTools, onChange]
)
const handleSelectWorkflow = useCallback(
(workflow: { id: string; name: string }) => {
const alreadySelected = selectedTools.some(
(t) => t.type === 'workflow_input' && t.params?.workflowId === workflow.id
)
if (alreadySelected) return
const newTool: ToolInput = {
type: 'workflow_input',
title: workflow.name,
toolId: 'workflow_executor',
params: { workflowId: workflow.id },
isExpanded: true,
usageControl: 'auto',
}
onChange([...selectedTools.map((t) => ({ ...t, isExpanded: false })), newTool])
},
[selectedTools, onChange]
)
const handleRemove = useCallback(
(index: number) => {
const next = [...selectedTools]
next.splice(index, 1)
onChange(next)
},
[selectedTools, onChange]
)
const handleParamChange = useCallback(
(index: number, paramId: string, value: string) => {
onChange(
selectedTools.map((t, i) =>
i === index ? { ...t, params: { ...(t.params || {}), [paramId]: value } } : t
)
)
},
[selectedTools, onChange]
)
const handleOperationChange = useCallback(
(index: number, operation: string) => {
const tool = selectedTools[index]
const newToolId = getToolIdForOperation(tool.type ?? '', operation) || ''
onChange(
selectedTools.map((t, i) =>
i === index ? { ...t, operation, toolId: newToolId, params: {} } : t
)
)
},
[selectedTools, onChange]
)
const handleUsageControlChange = useCallback(
(index: number, value: 'auto' | 'force' | 'none') => {
onChange(selectedTools.map((t, i) => (i === index ? { ...t, usageControl: value } : t)))
},
[selectedTools, onChange]
)
const toggleExpansion = useCallback(
(index: number) => {
onChange(selectedTools.map((t, i) => (i === index ? { ...t, isExpanded: !t.isExpanded } : t)))
},
[selectedTools, onChange]
)
const selectedCustomIds = useMemo(
() => new Set(selectedTools.filter((t) => t.type === 'custom-tool').map((t) => t.customToolId)),
[selectedTools]
)
const selectedMcpKeys = useMemo(
() =>
new Set(
selectedTools
.filter((t) => t.type === 'mcp')
.map((t) => `${t.params?.serverId ?? ''}:${t.params?.toolName ?? ''}`)
),
[selectedTools]
)
const showUsageControl = useMemo(() => {
if (!model) return false
const provider = getProviderFromModel(model)
return Boolean(provider && supportsToolUsageControl(provider))
}, [model])
const toolGroups = useMemo((): ComboboxOptionGroup[] => {
if (activeMcpServerId) {
const server = servers.find((s) => s.id === activeMcpServerId)
const serverTools = mcpTools.filter(
(t) => t.serverId === activeMcpServerId && !selectedMcpKeys.has(`${t.serverId}:${t.name}`)
)
const groups: ComboboxOptionGroup[] = [
{
items: [
{
label: 'Back',
value: 'action-back',
icon: ChevronLeft,
onSelect: () => setActiveMcpServerId(null),
keepOpen: true,
},
...(serverTools.length > 0
? [
{
label: `Use all ${serverTools.length} tool${serverTools.length !== 1 ? 's' : ''}`,
value: 'action-use-all',
icon: McpIcon,
onSelect: () => {
const newTools: ToolInput[] = serverTools.map((t) => ({
type: 'mcp',
title: t.name,
toolId: t.id,
params: {
serverId: t.serverId,
toolName: t.name,
serverName: t.serverName,
},
schema: t.inputSchema,
usageControl: 'auto' as const,
}))
onChange([...selectedTools, ...newTools])
setActiveMcpServerId(null)
},
},
]
: []),
],
},
]
if (serverTools.length > 0) {
groups.push({
section: server?.name ?? activeMcpServerId,
items: serverTools.map((tool) => ({
label: tool.name,
value: `mcp:${tool.serverId}:${tool.name}`,
icon: McpIcon,
onSelect: () => {
handleSelectMcpTool(tool)
setActiveMcpServerId(null)
},
})),
})
}
return groups
}
const groups: ComboboxOptionGroup[] = []
groups.push({
items: [
{
label: 'Create custom tool',
value: 'action-create-tool',
icon: Plus,
onSelect: () => setShowCustomToolModal(true),
},
{
label: 'Add MCP server',
value: 'action-add-mcp',
icon: Plus,
onSelect: () => setShowMcpModal(true),
},
],
})
const availableCustom = customTools.filter((t) => !selectedCustomIds.has(t.id))
if (availableCustom.length > 0) {
groups.push({
section: 'Custom tools',
items: availableCustom.map((t) => ({
label: t.title,
value: `custom:${t.id}`,
icon: Wrench,
onSelect: () => handleSelectCustomTool(t),
})),
})
}
const enabledServers = servers.filter((s) => s.enabled)
if (enabledServers.length > 0) {
groups.push({
section: 'MCP servers',
items: enabledServers.map((server) => {
const count = mcpTools.filter((t) => t.serverId === server.id).length
return {
label: server.name,
value: `server:${server.id}`,
icon: McpIcon,
suffixElement: (
<div className='flex items-center gap-[3px] text-[11px] text-[var(--text-muted)]'>
<span>{count}</span>
<ChevronRight className='h-[9px] w-[9px]' />
</div>
),
onSelect: () => setActiveMcpServerId(server.id),
keepOpen: true,
}
}),
})
}
const builtInBlocks = toolBlocks.filter((b) => BUILT_IN_TOOL_TYPES.has(b.type))
if (builtInBlocks.length > 0) {
groups.push({
section: 'Tools',
items: builtInBlocks.map((block) => ({
label: block.name,
value: `block:${block.type}`,
iconElement: (
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px]'
style={{ backgroundColor: block.bgColor }}
>
<block.icon className='h-[9px] w-[9px] text-white' />
</div>
),
onSelect: () => handleSelectBlock(block),
})),
})
}
const integrationBlocks = toolBlocks.filter((b) => !BUILT_IN_TOOL_TYPES.has(b.type))
if (integrationBlocks.length > 0) {
groups.push({
section: 'Integrations',
items: integrationBlocks.map((block) => ({
label: block.name,
value: `integration:${block.type}`,
iconElement: (
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px]'
style={{ backgroundColor: block.bgColor }}
>
<block.icon className='h-[9px] w-[9px] text-white' />
</div>
),
onSelect: () => handleSelectBlock(block),
})),
})
}
const selectedWorkflowIds = new Set(
selectedTools
.filter((t) => t.type === 'workflow_input')
.map((t) => t.params?.workflowId as string)
)
const availableWorkflows = workflowsList.filter((w) => !selectedWorkflowIds.has(w.id))
if (availableWorkflows.length > 0) {
groups.push({
section: 'Workflows',
items: availableWorkflows.map((workflow) => ({
label: workflow.name,
value: `workflow:${workflow.id}`,
iconElement: (
<div className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px] bg-[#6366F1]'>
<WorkflowIcon className='h-[9px] w-[9px] text-white' />
</div>
),
onSelect: () => handleSelectWorkflow(workflow),
})),
})
}
return groups
}, [
activeMcpServerId,
servers,
mcpTools,
selectedMcpKeys,
customTools,
selectedCustomIds,
toolBlocks,
selectedTools,
workflowsList,
onChange,
handleSelectMcpTool,
handleSelectCustomTool,
handleSelectBlock,
handleSelectWorkflow,
])
return (
<div className='w-full space-y-[8px]'>
<Combobox
options={[]}
groups={toolGroups}
placeholder='Add tool…'
searchable
searchPlaceholder='Search tools…'
maxHeight={280}
emptyMessage='No tools available'
onOpenChange={(open) => {
if (!open) setActiveMcpServerId(null)
}}
/>
{selectedTools.map((tool, idx) => (
<ToolCard
key={`${tool.type}-${tool.customToolId ?? ''}-${tool.toolId ?? ''}-${tool.params?.serverId ?? ''}-${tool.params?.toolName ?? ''}-${tool.params?.workflowId ?? ''}-${idx}`}
tool={tool}
toolIndex={idx}
agentId={agentId}
toolBlocks={toolBlocks}
showUsageControl={showUsageControl}
usageControlOpen={usageControlIndex === idx}
onUsageControlOpenChange={(open) => setUsageControlIndex(open ? idx : null)}
onRemove={() => handleRemove(idx)}
onParamChange={(paramId, value) => handleParamChange(idx, paramId, value)}
onOperationChange={(op) => handleOperationChange(idx, op)}
onUsageControlChange={(v) => handleUsageControlChange(idx, v)}
onToggleExpansion={() => toggleExpansion(idx)}
/>
))}
<CustomToolModal
open={showCustomToolModal}
onOpenChange={setShowCustomToolModal}
onSave={(tool: CustomTool) => {
if (tool.id) {
handleSelectCustomTool({ id: tool.id, title: tool.title })
}
}}
blockId=''
/>
<McpServerFormModal
open={showMcpModal}
onOpenChange={setShowMcpModal}
mode='add'
onSubmit={async (config) => {
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
}}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
</div>
)
}
interface ToolCardProps {
tool: ToolInput
toolIndex: number
agentId: string
toolBlocks: ReturnType<typeof getAllBlocks>
showUsageControl: boolean
usageControlOpen: boolean
onUsageControlOpenChange: (open: boolean) => void
onRemove: () => void
onParamChange: (paramId: string, value: string) => void
onOperationChange: (op: string) => void
onUsageControlChange: (value: 'auto' | 'force' | 'none') => void
onToggleExpansion: () => void
}
function ToolCard({
tool,
toolIndex,
agentId,
toolBlocks,
showUsageControl,
usageControlOpen,
onUsageControlOpenChange,
onRemove,
onParamChange,
onOperationChange,
onUsageControlChange,
onToggleExpansion,
}: ToolCardProps) {
const isCustomTool = tool.type === 'custom-tool'
const isMcpTool = tool.type === 'mcp'
const isWorkflowTool = tool.type === 'workflow_input' || tool.type === 'workflow'
const block = !isCustomTool && !isMcpTool ? toolBlocks.find((b) => b.type === tool.type) : null
const currentToolId =
!isCustomTool && !isMcpTool
? (getToolIdForOperation(tool.type ?? '', tool.operation) ?? tool.toolId ?? '')
: (tool.toolId ?? '')
const subBlocksResult =
!isCustomTool && !isMcpTool && currentToolId
? getSubBlocksForToolInput(currentToolId, tool.type ?? '', {
operation: tool.operation,
...(tool.params || {}),
})
: null
// Exclude input-mapping sub-blocks — the LLM resolves these at runtime
const displaySubBlocks = (subBlocksResult?.subBlocks ?? []).filter(
(sb) => sb.type !== 'input-mapping'
)
const hasOps = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type ?? '')
const hasBody = hasOps || displaySubBlocks.length > 0
const isExpanded = hasBody ? !!tool.isExpanded : false
const iconNode = isMcpTool ? (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#ede9fb]'>
<McpIcon className='h-[10px] w-[10px] text-[#6b5fc9]' />
</div>
) : isCustomTool ? (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#dbeafe]'>
<Wrench className='h-[10px] w-[10px] text-[#3b82f6]' />
</div>
) : isWorkflowTool ? (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#6366F1]'>
<WorkflowIcon className='h-[10px] w-[10px] text-white' />
</div>
) : block ? (
<div
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ backgroundColor: block.bgColor }}
>
<block.icon className='h-[10px] w-[10px] text-white' />
</div>
) : null
const displayTitle = tool.title ?? tool.params?.toolName ?? tool.type
const subtitle =
tool.type === 'mcp' && tool.params?.serverName ? String(tool.params.serverName) : undefined
return (
<div
className={cn(
'group relative flex flex-col overflow-hidden rounded-[4px] border border-[var(--border-1)] transition-all duration-200 ease-in-out',
isExpanded ? 'rounded-b-[4px]' : ''
)}
>
{/* Header row */}
<div
className={cn(
'flex items-center justify-between gap-[8px] bg-[var(--surface-4)] px-[8px] py-[6.5px]',
hasBody ? 'cursor-pointer rounded-t-[4px]' : 'rounded-[4px]'
)}
onClick={hasBody ? onToggleExpansion : undefined}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{iconNode}
<div className='min-w-0 flex-1'>
<span className='block truncate font-medium text-[13px] text-[var(--text-primary)]'>
{displayTitle}
</span>
{subtitle && (
<span className='block truncate text-[11px] text-[var(--text-muted)]'>
{subtitle}
</span>
)}
</div>
</div>
<div className='flex flex-shrink-0 items-center gap-[6px]'>
{showUsageControl && (
<Popover open={usageControlOpen} onOpenChange={onUsageControlOpenChange}>
<PopoverTrigger asChild>
<button
type='button'
onClick={(e) => e.stopPropagation()}
className='flex items-center justify-center font-medium text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Tool usage control'
>
{tool.usageControl === 'force'
? 'Force'
: tool.usageControl === 'none'
? 'None'
: 'Auto'}
</button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={8}
onClick={(e) => e.stopPropagation()}
className='gap-[2px]'
border
>
<PopoverItem
active={(tool.usageControl || 'auto') === 'auto'}
onClick={() => {
onUsageControlChange('auto')
onUsageControlOpenChange(false)
}}
>
Auto <span className='text-[var(--text-tertiary)]'>(model decides)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'force'}
onClick={() => {
onUsageControlChange('force')
onUsageControlOpenChange(false)
}}
>
Force <span className='text-[var(--text-tertiary)]'>(always use)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'none'}
onClick={() => {
onUsageControlChange('none')
onUsageControlOpenChange(false)
}}
>
None
</PopoverItem>
</PopoverContent>
</Popover>
)}
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Remove tool'
>
<X className='h-[13px] w-[13px]' />
</button>
</div>
</div>
{/* Expanded body */}
{isExpanded && (
<ReactFlowProvider>
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[8px] py-[8px]'>
{/* Operation selector */}
{hasOps &&
(() => {
const opOptions = getOperationOptions(tool.type ?? '')
return opOptions.length > 0 ? (
<div className='space-y-[6px]'>
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Operation
</div>
<Combobox
options={opOptions
.filter((o) => o.id !== '')
.map((o) => ({ label: o.label, value: o.id }))}
value={tool.operation || opOptions[0]?.id || ''}
onChange={(v) => onOperationChange(v)}
placeholder='Select operation'
/>
</div>
) : null
})()}
{/* Sub-block params */}
{displaySubBlocks.map((sb) => (
<ToolSubBlockRenderer
key={sb.id}
blockId={agentId}
subBlockId='agent-tools'
toolIndex={toolIndex}
subBlock={{ ...sb, title: sb.title || sb.id }}
effectiveParamId={sb.id}
toolParams={tool.params as Record<string, string> | undefined}
onParamChange={(_, paramId, value) => onParamChange(paramId, value)}
disabled={false}
/>
))}
</div>
</ReactFlowProvider>
)}
</div>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Button, Label } from '@/components/emcn'
import { Check, Copy } from '@/components/emcn/icons'
import { useUpdateAgent } from '@/hooks/queries/agents'
const logger = createLogger('ApiDeploy')
interface ApiDeployProps {
agentId: string
workspaceId: string
isDeployed: boolean
}
export function ApiDeploy({ agentId, workspaceId, isDeployed }: ApiDeployProps) {
const { mutateAsync: updateAgent, isPending } = useUpdateAgent()
const [copied, setCopied] = useState(false)
const endpoint =
typeof window !== 'undefined'
? `${window.location.origin}/api/v1/agents/${agentId}`
: `/api/v1/agents/${agentId}`
const curlExample = `curl -X POST "${endpoint}" \\
-H "Authorization: Bearer <your-api-key>" \\
-H "Content-Type: application/json" \\
-d '{"message": "Hello, agent!"}'`
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(curlExample)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
/* ignore */
}
}
const handleToggle = async () => {
try {
await updateAgent({ agentId, isDeployed: !isDeployed })
} catch (error) {
logger.error('Failed to toggle API deployment', { error })
}
}
return (
<div className='space-y-[16px]'>
<p className='text-[13px] text-[var(--text-muted)]'>
Call your agent via REST API using your workspace API key.
</p>
<div className='flex items-center justify-between'>
<Label className='text-[13px]'>API access</Label>
<Button variant='subtle' size='sm' onClick={handleToggle} disabled={isPending}>
{isDeployed ? 'Disable' : 'Enable'}
</Button>
</div>
{isDeployed && (
<>
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>Endpoint</Label>
<div className='flex items-center gap-[8px] rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[7px]'>
<code className='flex-1 truncate text-[12px] text-[var(--text-primary)]'>
{endpoint}
</code>
</div>
</div>
<div>
<div className='mb-[6px] flex items-center justify-between'>
<Label className='text-[12px] text-[var(--text-muted)]'>Example request</Label>
<button
type='button'
onClick={handleCopy}
className='flex items-center gap-[4px] text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
{copied ? (
<>
<Check className='h-[11px] w-[11px]' />
Copied
</>
) : (
<>
<Copy className='h-[11px] w-[11px]' />
Copy
</>
)}
</button>
</div>
<pre className='overflow-x-auto rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[8px] text-[11px] text-[var(--text-primary)] leading-[1.6]'>
{curlExample}
</pre>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,362 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Button, Combobox, type ComboboxOption, Input, Label } from '@/components/emcn'
import { Check, Loader, Plus } from '@/components/emcn/icons'
import type { AgentDeploymentRow, SlackDeploymentConfig } from '@/lib/agents/types'
import { cn } from '@/lib/core/utils/cn'
import { getCanonicalScopesForProvider } from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import {
useAgent,
useDeployAgentToSlack,
useSlackChannels,
useUndeployAgentFromSlack,
} from '@/hooks/queries/agents'
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
const logger = createLogger('SlackDeploy')
interface SlackDeployProps {
agentId: string
workspaceId: string
}
const RESPOND_TO_OPTIONS: ComboboxOption[] = [
{ value: 'mentions', label: '@mentions only' },
{ value: 'all', label: 'All messages in channel' },
{ value: 'threads', label: 'Thread replies only' },
{ value: 'dm', label: 'Direct messages (DMs)' },
]
export function SlackDeploy({ agentId, workspaceId }: SlackDeployProps) {
const { data: agent } = useAgent(agentId)
const { data: credentials = [], isLoading: isLoadingCredentials } = useWorkspaceCredentials({
workspaceId,
type: 'oauth',
providerId: 'slack',
})
const existingDeployment = agent?.deployments?.find((d) => d.platform === 'slack')
return (
<SlackDeployForm
key={existingDeployment?.id ?? 'new'}
agentId={agentId}
existingDeployment={existingDeployment}
credentials={credentials}
isLoadingCredentials={isLoadingCredentials}
/>
)
}
interface SlackDeployFormProps {
agentId: string
existingDeployment: AgentDeploymentRow | undefined
credentials: WorkspaceCredential[]
isLoadingCredentials: boolean
}
function SlackDeployForm({
agentId,
existingDeployment,
credentials,
isLoadingCredentials,
}: SlackDeployFormProps) {
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [deployError, setDeployError] = useState<string | null>(null)
const { mutateAsync: deploy, isPending: isDeploying } = useDeployAgentToSlack()
const { mutateAsync: undeploy, isPending: isUndeploying } = useUndeployAgentFromSlack()
const existingCfg = existingDeployment?.config as SlackDeploymentConfig | undefined
const [selectedCredentialId, setSelectedCredentialId] = useState(
existingDeployment?.credentialId ?? ''
)
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>(
existingCfg?.channelIds ?? []
)
const [respondTo, setRespondTo] = useState<SlackDeploymentConfig['respondTo']>(
existingCfg?.respondTo ?? 'mentions'
)
const [botName, setBotName] = useState(existingCfg?.botName ?? '')
const [replyInThread, setReplyInThread] = useState(existingCfg?.replyInThread !== false)
const isDeployed = Boolean(existingDeployment?.isActive)
const isDmMode = respondTo === 'dm'
const { data: channels = [], isLoading: isLoadingChannels } =
useSlackChannels(selectedCredentialId)
const handleCredentialChange = useCallback((id: string) => {
setSelectedCredentialId(id)
setSelectedChannelIds([])
}, [])
const handleDeploy = useCallback(async () => {
if (!selectedCredentialId) return
if (!isDmMode && selectedChannelIds.length === 0) return
setDeployError(null)
try {
await deploy({
agentId,
credentialId: selectedCredentialId,
channelIds: isDmMode ? [] : selectedChannelIds,
respondTo,
botName: botName.trim() || undefined,
replyInThread,
})
} catch (error) {
logger.error('Failed to deploy agent to Slack', { error })
setDeployError(error instanceof Error ? error.message : 'Deployment failed')
}
}, [
agentId,
botName,
deploy,
isDmMode,
respondTo,
replyInThread,
selectedChannelIds,
selectedCredentialId,
])
const handleUndeploy = useCallback(async () => {
try {
await undeploy({ agentId })
} catch (error) {
logger.error('Failed to undeploy agent from Slack', { error })
}
}, [agentId, undeploy])
const toggleChannel = useCallback((channelId: string) => {
setSelectedChannelIds((prev) =>
prev.includes(channelId) ? prev.filter((id) => id !== channelId) : [...prev, channelId]
)
}, [])
const credentialOptions: ComboboxOption[] = useMemo(
() => credentials.map((c) => ({ value: c.id, label: c.displayName })),
[credentials]
)
const hasValidConfig = selectedCredentialId && (isDmMode || selectedChannelIds.length > 0)
return (
<>
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider='slack'
toolName='Slack'
requiredScopes={getCanonicalScopesForProvider('slack')}
serviceId='slack'
/>
<div className='flex flex-col gap-[16px]'>
{/* Workspace */}
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Slack workspace
</Label>
{isLoadingCredentials ? (
<div className='flex h-[36px] items-center gap-[6px] text-[12px] text-[var(--text-muted)]'>
<Loader className='h-[12px] w-[12px] animate-spin' />
Loading
</div>
) : credentials.length === 0 ? (
<div className='flex flex-col gap-[8px]'>
<p className='text-[12px] text-[var(--text-muted)]'>
No Slack workspace connected yet.
</p>
<Button
variant='outline'
size='sm'
onClick={() => setShowOAuthModal(true)}
className='w-fit'
>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Connect Slack
</Button>
</div>
) : (
<div className='flex items-center gap-[8px]'>
<div className='flex-1'>
<Combobox
options={credentialOptions}
value={selectedCredentialId}
onChange={handleCredentialChange}
placeholder='Select a Slack workspace…'
/>
</div>
<button
type='button'
onClick={() => setShowOAuthModal(true)}
className='flex-shrink-0 text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
title='Connect another workspace'
>
<Plus className='h-[14px] w-[14px]' />
</button>
</div>
)}
</div>
{selectedCredentialId && (
<>
{/* Respond to */}
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Respond to
</Label>
<Combobox
options={RESPOND_TO_OPTIONS}
value={respondTo}
onChange={(v) => setRespondTo(v as SlackDeploymentConfig['respondTo'])}
/>
</div>
{/* Channels — hidden in DM mode */}
{!isDmMode && (
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Channels <span className='font-normal opacity-60'>(select one or more)</span>
</Label>
{isLoadingChannels ? (
<div className='flex h-[32px] items-center gap-[6px] text-[12px] text-[var(--text-muted)]'>
<Loader className='h-[12px] w-[12px] animate-spin' />
Loading channels
</div>
) : channels.length === 0 ? (
<p className='text-[12px] text-[var(--text-muted)]'>
No accessible channels found. Make sure the bot has been added to at least one
channel.
</p>
) : (
<div className='max-h-[160px] overflow-y-auto rounded-[4px] border border-[var(--border-1)]'>
{channels.map((channel) => (
<button
key={channel.id}
type='button'
onClick={() => toggleChannel(channel.id)}
className={cn(
'flex w-full items-center gap-[8px] px-[10px] py-[7px] text-left text-[12px] transition-colors',
'hover:bg-[var(--surface-7)]',
selectedChannelIds.includes(channel.id) && 'bg-[var(--surface-4)]'
)}
>
<span
className={cn(
'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px] border',
selectedChannelIds.includes(channel.id)
? 'border-[var(--text-primary)] bg-[var(--text-primary)]'
: 'border-[var(--border-1)]'
)}
>
{selectedChannelIds.includes(channel.id) && (
<Check className='h-[9px] w-[9px] text-[var(--surface-1)]' />
)}
</span>
<span className='text-[var(--text-primary)]'>
{channel.isPrivate ? '🔒 ' : '#'}
{channel.name}
</span>
</button>
))}
</div>
)}
</div>
)}
{/* Bot display name */}
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Bot display name <span className='font-normal opacity-60'>(optional)</span>
</Label>
<Input
value={botName}
onChange={(e) => setBotName(e.target.value)}
placeholder='e.g. Sales Assistant'
maxLength={80}
className='h-[34px] text-[12px]'
/>
</div>
{/* Reply in thread */}
<div className='flex items-center justify-between'>
<div>
<p className='text-[12px] text-[var(--text-primary)]'>Reply in thread</p>
<p className='text-[11px] text-[var(--text-muted)]'>
Keep responses inside the original thread
</p>
</div>
<button
type='button'
onClick={() => setReplyInThread((v) => !v)}
className={cn(
'relative h-[20px] w-[36px] flex-shrink-0 rounded-full transition-colors',
replyInThread ? 'bg-[var(--text-primary)]' : 'bg-[var(--border-1)]'
)}
aria-checked={replyInThread}
role='switch'
>
<span
className={cn(
'absolute top-[2px] h-[16px] w-[16px] rounded-full bg-white shadow-sm transition-transform',
replyInThread ? 'translate-x-[18px]' : 'translate-x-[2px]'
)}
/>
</button>
</div>
</>
)}
{/* Error */}
{deployError && (
<p className='rounded-[4px] bg-[var(--error-bg,#fef2f2)] px-[10px] py-[6px] text-[12px] text-[var(--error,#dc2626)]'>
{deployError}
</p>
)}
{/* Actions */}
<div className='flex items-center gap-[8px] pt-[4px]'>
{isDeployed ? (
<>
<div className='flex items-center gap-[6px] text-[12px] text-[var(--text-success,#16a34a)]'>
<span className='h-[6px] w-[6px] rounded-full bg-[var(--text-success,#16a34a)]' />
Deployed
</div>
<Button
variant='outline'
size='sm'
onClick={handleDeploy}
disabled={!hasValidConfig || isDeploying}
className='ml-auto'
>
{isDeploying ? (
<Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' />
) : null}
Update
</Button>
<Button variant='outline' size='sm' onClick={handleUndeploy} disabled={isUndeploying}>
{isUndeploying ? (
<Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' />
) : null}
Remove
</Button>
</>
) : (
<Button
size='sm'
onClick={handleDeploy}
disabled={!hasValidConfig || isDeploying}
className='ml-auto'
>
{isDeploying ? <Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' /> : null}
Deploy to Slack
</Button>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,531 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Paperclip, Square } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Send, X } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { cn } from '@/lib/core/utils/cn'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
const logger = createLogger('AgentTestPanel')
const MAX_FILES = 10
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const TEXT_EXTENSIONS = new Set([
'.txt',
'.md',
'.csv',
'.json',
'.xml',
'.html',
'.ts',
'.tsx',
'.js',
'.jsx',
'.py',
'.sh',
])
const TEXT_MEDIA_TYPES = new Set([
'text/plain',
'text/markdown',
'text/csv',
'application/json',
'text/html',
'application/xml',
'text/xml',
])
interface AttachedFile {
id: string
name: string
size: number
type: string
file: File
previewUrl?: string
}
interface ChatMessage {
role: 'user' | 'assistant'
content: string
isStreaming?: boolean
attachments?: Array<{ id: string; name: string; type: string; previewUrl?: string }>
}
interface AgentTestPanelProps {
agentId: string
}
function isTextFile(file: { type: string; name: string }) {
if (TEXT_MEDIA_TYPES.has(file.type)) return true
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
return TEXT_EXTENSIONS.has(ext)
}
function StreamingCursor() {
return (
<span className='ml-[1px] inline-block h-[13px] w-[5px] translate-y-[1px] animate-pulse bg-current opacity-60' />
)
}
function FilePill({
name,
type,
previewUrl,
onRemove,
}: {
name: string
type: string
previewUrl?: string
onRemove?: () => void
}) {
const isImage = type.startsWith('image/')
const Icon = getDocumentIcon(type, name)
if (isImage && previewUrl) {
return (
<div className='group relative h-[44px] w-[44px] flex-shrink-0 overflow-hidden rounded-[6px]'>
<img src={previewUrl} alt={name} className='h-full w-full object-cover' />
{onRemove && (
<button
type='button'
onClick={onRemove}
className='absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100'
aria-label={`Remove ${name}`}
>
<X className='h-[11px] w-[11px] text-white' />
</button>
)}
</div>
)
}
return (
<div className='flex max-w-[160px] items-center gap-[4px] rounded-[6px] bg-[var(--surface-4)] px-[7px] py-[3px]'>
<Icon className='h-[11px] w-[11px] flex-shrink-0 text-[var(--text-muted)]' />
<span className='min-w-0 truncate text-[11px] text-[var(--text-primary)]'>{name}</span>
{onRemove && (
<button
type='button'
onClick={onRemove}
className='ml-[2px] flex-shrink-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
aria-label={`Remove ${name}`}
>
<X className='h-[9px] w-[9px]' />
</button>
)}
</div>
)
}
function UserMessage({ message }: { message: ChatMessage }) {
const hasAttachments = (message.attachments?.length ?? 0) > 0
return (
<div className='flex w-full flex-col items-end gap-[4px]'>
{hasAttachments && (
<div className='flex flex-wrap justify-end gap-[5px]'>
{message.attachments!.map((att) => (
<FilePill key={att.id} name={att.name} type={att.type} previewUrl={att.previewUrl} />
))}
</div>
)}
{message.content && (
<div className='max-w-[85%] rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[7px]'>
<p className='whitespace-pre-wrap break-words font-medium text-[13px] text-[var(--text-primary)] leading-[1.5]'>
{message.content}
</p>
</div>
)}
</div>
)
}
const REMARK_PLUGINS = [remarkGfm]
const PROSE_CLASSES = cn(
'prose prose-sm dark:prose-invert max-w-none',
'prose-p:text-[13px] prose-p:leading-[1.6] prose-p:text-[var(--text-primary)] first:prose-p:mt-0 last:prose-p:mb-0',
'prose-headings:text-[var(--text-primary)] prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2',
'prose-li:text-[13px] prose-li:text-[var(--text-primary)] prose-li:my-0.5',
'prose-ul:my-2 prose-ol:my-2',
'prose-strong:text-[var(--text-primary)] prose-strong:font-semibold',
'prose-a:text-[var(--text-primary)] prose-a:underline prose-a:decoration-dashed prose-a:underline-offset-2',
'prose-code:rounded prose-code:bg-[var(--surface-4)] prose-code:px-1 prose-code:py-0.5 prose-code:text-[12px] prose-code:font-mono prose-code:text-[var(--text-primary)]',
'prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-[var(--surface-4)] prose-pre:border prose-pre:border-[var(--border-1)] prose-pre:rounded-[6px] prose-pre:text-[12px]',
'prose-blockquote:border-[var(--border-1)] prose-blockquote:text-[var(--text-muted)]'
)
function AssistantMessage({ message }: { message: ChatMessage }) {
return (
<div className='w-full pl-[2px]'>
{message.isStreaming && !message.content ? (
<span className='text-[13px] text-[var(--text-tertiary)]'>
Thinking
<StreamingCursor />
</span>
) : message.content ? (
<div className={PROSE_CLASSES}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{message.content}</ReactMarkdown>
{message.isStreaming && <StreamingCursor />}
</div>
) : null}
</div>
)
}
export function AgentTestPanel({ agentId }: AgentTestPanelProps) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
const conversationIdRef = useRef(`test-${Date.now()}`)
const scrollRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
return () => {
abortRef.current?.abort()
// Revoke any object URLs created for image previews
for (const f of attachedFiles) {
if (f.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(f.previewUrl)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
}, [messages])
const syncHeight = useCallback(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 180)}px`
}, [])
const addFiles = useCallback((files: File[]) => {
setAttachedFiles((current) => {
const slots = Math.max(0, MAX_FILES - current.length)
const next: AttachedFile[] = []
for (const file of files.slice(0, slots)) {
if (file.size > MAX_FILE_SIZE) continue
if ([...current, ...next].some((f) => f.name === file.name && f.size === file.size))
continue
const attached: AttachedFile = {
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
file,
}
if (file.type.startsWith('image/')) {
attached.previewUrl = URL.createObjectURL(file)
}
next.push(attached)
}
return [...current, ...next]
})
}, [])
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(Array.from(e.target.files))
e.target.value = ''
},
[addFiles]
)
const handleStop = useCallback(() => {
abortRef.current?.abort()
}, [])
const handleSend = useCallback(async () => {
const trimmed = input.trim()
if ((!trimmed && attachedFiles.length === 0) || isLoading) return
const files = [...attachedFiles]
setInput('')
setAttachedFiles([])
setIsLoading(true)
if (textareaRef.current) textareaRef.current.style.height = 'auto'
// Inject text file contents into the message
let messageText = trimmed
const textFiles = files.filter(isTextFile)
if (textFiles.length > 0) {
const contents = await Promise.all(
textFiles.map(
(f) =>
new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = (ev) => resolve(`\n\n--- ${f.name} ---\n${ev.target?.result ?? ''}`)
reader.onerror = () => resolve('')
reader.readAsText(f.file)
})
)
)
messageText = (trimmed + contents.join('')).trim()
} else if (!trimmed && files.length > 0) {
messageText = files.map((f) => f.name).join(', ')
}
const attachments = files.map(({ id, name, type, previewUrl }) => ({
id,
name,
type,
previewUrl,
}))
setMessages((prev) => [
...prev,
{ role: 'user', content: trimmed, attachments },
{ role: 'assistant', content: '', isStreaming: true },
])
const controller = new AbortController()
abortRef.current = controller
try {
const res = await fetch(`/api/agents/${agentId}/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ message: messageText, conversationId: conversationIdRef.current }),
signal: controller.signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.error || 'Execution failed')
}
const contentType = res.headers.get('content-type') ?? ''
if (contentType.includes('text/event-stream') && res.body) {
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let accumulated = ''
outer: while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]') break outer
try {
const parsed = JSON.parse(data)
if (parsed.content) {
accumulated += parsed.content
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = {
role: 'assistant',
content: accumulated,
isStreaming: true,
}
return next
})
}
} catch {}
}
}
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = { role: 'assistant', content: accumulated, isStreaming: false }
return next
})
} else {
const json = await res.json()
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = {
role: 'assistant',
content: json.data?.content ?? '',
isStreaming: false,
}
return next
})
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
setMessages((prev) => {
const next = [...prev]
const last = next[next.length - 1]
if (last?.role === 'assistant') next[next.length - 1] = { ...last, isStreaming: false }
return next
})
return
}
logger.error('Agent execution failed', { error })
const msg = error instanceof Error ? error.message : 'Something went wrong'
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = { role: 'assistant', content: `Error: ${msg}`, isStreaming: false }
return next
})
} finally {
setIsLoading(false)
abortRef.current = null
setTimeout(() => textareaRef.current?.focus(), 0)
}
}, [agentId, input, attachedFiles, isLoading])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
},
[handleSend]
)
const canSend = (input.trim().length > 0 || attachedFiles.length > 0) && !isLoading
return (
<div
className='flex h-full flex-col bg-[var(--surface-1)]'
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
addFiles(Array.from(e.dataTransfer.files))
}}
>
<div className='flex flex-shrink-0 items-center justify-between border-[var(--border-1)] border-b px-[16px] py-[10px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Test</span>
{messages.length > 0 && (
<button
type='button'
onClick={() => {
setMessages([])
setAttachedFiles([])
conversationIdRef.current = `test-${Date.now()}`
}}
className='text-[12px] text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
>
Clear
</button>
)}
</div>
<div ref={scrollRef} className='flex-1 overflow-y-auto px-[20px] py-[20px]'>
{messages.length === 0 ? (
<div className='flex h-full flex-col items-center justify-center gap-[6px]'>
<p className='text-[13px] text-[var(--text-muted)]'>
Send a message to test your agent.
</p>
<p className='text-[11px] text-[var(--text-tertiary)]'>
to send · Shift+ for new line
</p>
</div>
) : (
<div className='flex flex-col gap-[20px]'>
{messages.map((msg, idx) =>
msg.role === 'user' ? (
<UserMessage key={idx} message={msg} />
) : (
<AssistantMessage key={idx} message={msg} />
)
)}
</div>
)}
</div>
<div className='flex-shrink-0 p-[12px]'>
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
{attachedFiles.length > 0 && (
<div className='flex flex-wrap gap-[5px] px-[10px] pt-[8px]'>
{attachedFiles.map((file) => (
<FilePill
key={file.id}
name={file.name}
type={file.type}
previewUrl={file.previewUrl}
onRemove={() => {
if (file.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(file.previewUrl)
setAttachedFiles((prev) => prev.filter((f) => f.id !== file.id))
}}
/>
))}
</div>
)}
<div className='flex items-end px-[10px] py-[6px]'>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => {
setInput(e.target.value)
syncHeight()
}}
onKeyDown={handleKeyDown}
placeholder='Send a message…'
rows={1}
disabled={isLoading}
className={cn(
'max-h-[180px] min-h-[24px] flex-1 resize-none bg-transparent py-[2px] text-[13px] text-[var(--text-primary)] leading-[1.5]',
'placeholder:text-[var(--text-tertiary)] focus:outline-none disabled:opacity-50',
'overflow-hidden'
)}
/>
</div>
<div className='flex items-center justify-between px-[8px] pb-[6px]'>
<button
type='button'
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className='flex h-[26px] w-[26px] items-center justify-center rounded-[4px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)] disabled:opacity-40'
aria-label='Attach file'
>
<Paperclip className='h-[13px] w-[13px]' />
</button>
<button
type='button'
onClick={isLoading ? handleStop : handleSend}
disabled={!isLoading && !canSend}
className={cn(
'flex h-[26px] w-[26px] items-center justify-center rounded-[4px] transition-colors',
isLoading
? 'bg-[var(--surface-4)] text-[var(--text-primary)] hover:bg-[var(--border-1)]'
: canSend
? 'bg-[var(--text-primary)] text-[var(--surface-1)] hover:opacity-85'
: 'cursor-not-allowed text-[var(--text-tertiary)]'
)}
aria-label={isLoading ? 'Stop' : 'Send'}
>
{isLoading ? (
<Square className='h-[10px] w-[10px]' />
) : (
<Send className='h-[12px] w-[12px]' />
)}
</button>
</div>
</div>
</div>
<input
ref={fileInputRef}
type='file'
multiple
className='hidden'
onChange={handleFileChange}
accept={CHAT_ACCEPT_ATTRIBUTE}
/>
</div>
)
}

View File

@@ -0,0 +1,52 @@
'use client'
import {
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalTabs,
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
} from '@/components/emcn'
import { ApiDeploy } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-deploy/api-deploy'
import { SlackDeploy } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-deploy/slack-deploy'
interface DeployModalProps {
agentId: string
workspaceId: string
isDeployed: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
export function DeployModal({
agentId,
workspaceId,
isDeployed,
open,
onOpenChange,
}: DeployModalProps) {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='md'>
<ModalHeader>Deploy agent</ModalHeader>
<ModalTabs defaultValue='slack'>
<ModalTabsList>
<ModalTabsTrigger value='slack'>Slack</ModalTabsTrigger>
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
</ModalTabsList>
<ModalBody>
<ModalTabsContent value='slack'>
<SlackDeploy agentId={agentId} workspaceId={workspaceId} />
</ModalTabsContent>
<ModalTabsContent value='api'>
<ApiDeploy agentId={agentId} workspaceId={workspaceId} isDeployed={isDeployed} />
</ModalTabsContent>
</ModalBody>
</ModalTabs>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,154 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('AgentWand')
interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
interface UseAgentWandOptions {
/** System prompt passed verbatim to /api/wand. Use {context} as a placeholder for the current value. */
systemPrompt: string
generationType?: string
maintainHistory?: boolean
currentValue: string
onGeneratedContent: (content: string) => void
}
export interface AgentWandState {
isSearchActive: boolean
searchQuery: string
isStreaming: boolean
searchInputRef: React.RefObject<HTMLInputElement | null>
onSearchClick: () => void
onSearchBlur: () => void
onSearchChange: (value: string) => void
onSearchSubmit: () => void
onSearchCancel: () => void
}
export function useAgentWand({
systemPrompt,
generationType,
maintainHistory = false,
currentValue,
onGeneratedContent,
}: UseAgentWandOptions): AgentWandState {
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [history, setHistory] = useState<ChatMessage[]>([])
const searchInputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
const searchQueryRef = useRef(searchQuery)
searchQueryRef.current = searchQuery
const onSearchClick = useCallback(() => {
setIsSearchActive(true)
setTimeout(() => searchInputRef.current?.focus(), 50)
}, [])
const onSearchBlur = useCallback(() => {
if (!searchQueryRef.current.trim() && !abortRef.current) {
setIsSearchActive(false)
}
}, [])
const onSearchChange = useCallback((value: string) => {
setSearchQuery(value)
}, [])
const onSearchCancel = useCallback(() => {
abortRef.current?.abort()
abortRef.current = null
setIsSearchActive(false)
setSearchQuery('')
setIsStreaming(false)
}, [])
const onSearchSubmit = useCallback(async () => {
const prompt = searchQueryRef.current
if (!prompt.trim()) return
const resolvedSystemPrompt = systemPrompt.replace('{context}', currentValue)
setSearchQuery('')
setIsSearchActive(false)
setIsStreaming(true)
const controller = new AbortController()
abortRef.current = controller
try {
const res = await fetch('/api/wand', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
systemPrompt: resolvedSystemPrompt,
stream: true,
generationType,
history: maintainHistory ? history : [],
}),
signal: controller.signal,
})
if (!res.ok || !res.body) throw new Error('Wand generation failed')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let accumulated = ''
outer: while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const parsed = JSON.parse(line.slice(6))
if (parsed.done) break outer
if (parsed.chunk) {
accumulated += parsed.chunk
onGeneratedContent(accumulated)
}
} catch {}
}
}
if (maintainHistory) {
setHistory((prev) => [
...prev,
{ role: 'user', content: prompt },
{ role: 'assistant', content: accumulated },
])
}
} catch (error) {
if (!(error instanceof Error && error.name === 'AbortError')) {
logger.error('Wand generation failed', { error })
}
} finally {
setIsStreaming(false)
abortRef.current = null
}
}, [systemPrompt, generationType, maintainHistory, currentValue, history, onGeneratedContent])
return {
isSearchActive,
searchQuery,
isStreaming,
searchInputRef,
onSearchClick,
onSearchBlur,
onSearchChange,
onSearchSubmit,
onSearchCancel,
}
}

View File

@@ -0,0 +1,3 @@
export default function AgentDetailLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
}

View File

@@ -0,0 +1,38 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { AgentDetail } from '@/app/workspace/[workspaceId]/agents/[agentId]/agent-detail'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export const metadata: Metadata = {
title: 'Agent',
}
interface AgentDetailPageProps {
params: Promise<{
workspaceId: string
agentId: string
}>
}
export default async function AgentDetailPage({ params }: AgentDetailPageProps) {
const { workspaceId, agentId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideAgentsTab) {
redirect(`/workspace/${workspaceId}`)
}
return <AgentDetail agentId={agentId} workspaceId={workspaceId} />
}

View File

@@ -0,0 +1,224 @@
'use client'
import { useCallback, useDeferredValue, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { AgentIcon } from '@/components/icons'
import { AgentContextMenu } from '@/app/workspace/[workspaceId]/agents/components/agent-context-menu'
import { AgentListContextMenu } from '@/app/workspace/[workspaceId]/agents/components/agent-list-context-menu'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import {
InlineRenameInput,
ownerCell,
Resource,
timeCell,
} from '@/app/workspace/[workspaceId]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
useAgentsList,
useCreateAgent,
useDeleteAgent,
useUpdateAgent,
} from '@/hooks/queries/agents'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useInlineRename } from '@/hooks/use-inline-rename'
const logger = createLogger('Agents')
const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'model', header: 'Model' },
{ id: 'status', header: 'Status' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
export function Agents() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { data: agents = [], isLoading } = useAgentsList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
const { mutateAsync: createAgent, isPending: isCreating } = useCreateAgent()
const { mutateAsync: deleteAgent } = useDeleteAgent()
const { mutateAsync: updateAgent } = useUpdateAgent()
const userPermissions = useUserPermissionsContext()
const [activeAgentId, setActiveAgentId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const deferredSearch = useDeferredValue(searchQuery)
const listRename = useInlineRename({
onSave: (agentId, name) => updateAgent({ agentId, name }),
})
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
const {
isOpen: isRowContextMenuOpen,
position: rowContextMenuPosition,
handleContextMenu: handleRowCtxMenu,
closeMenu: closeRowContextMenu,
} = useContextMenu()
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (
target.closest('[data-resource-row]') ||
target.closest('button, input, a, [role="button"]')
)
return
handleListContextMenu(e)
},
[handleListContextMenu]
)
const handleRowClick = useCallback(
(rowId: string) => {
if (isRowContextMenuOpen || listRename.editingId === rowId) return
router.push(`/workspace/${workspaceId}/agents/${rowId}`)
},
[isRowContextMenuOpen, listRename.editingId, router, workspaceId]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
setActiveAgentId(rowId)
handleRowCtxMenu(e)
},
[handleRowCtxMenu]
)
const handleDelete = useCallback(async () => {
if (!activeAgentId) return
setIsDeleting(true)
try {
await deleteAgent({ agentId: activeAgentId })
closeRowContextMenu()
setActiveAgentId(null)
} catch (error) {
logger.error('Failed to delete agent', { error })
} finally {
setIsDeleting(false)
}
}, [activeAgentId, deleteAgent, closeRowContextMenu])
const handleCreateAgent = useCallback(async () => {
const existingNames = new Set(agents.map((a) => a.name))
let counter = 1
while (existingNames.has(`Agent ${counter}`)) counter++
const name = `Agent ${counter}`
try {
const agent = await createAgent({ workspaceId, name })
router.push(`/workspace/${workspaceId}/agents/${agent.id}`)
} catch (error) {
logger.error('Failed to create agent', { error })
}
}, [agents, createAgent, router, workspaceId])
const handleRename = useCallback(() => {
if (!activeAgentId) return
const agent = agents.find((a) => a.id === activeAgentId)
if (agent) listRename.startRename(activeAgentId, agent.name)
closeRowContextMenu()
}, [activeAgentId, agents, listRename, closeRowContextMenu])
const rows: ResourceRow[] = useMemo(() => {
const filtered = deferredSearch
? agents.filter((a) => a.name.toLowerCase().includes(deferredSearch.toLowerCase()))
: agents
return filtered.map((agent) => ({
id: agent.id,
cells: {
name: {
icon: <AgentIcon className='h-[14px] w-[14px]' />,
label: agent.name,
content:
listRename.editingId === agent.id ? (
<span className='flex min-w-0 items-center gap-[12px] font-medium text-[14px] text-[var(--text-body)]'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<AgentIcon className='h-[14px] w-[14px]' />
</span>
<InlineRenameInput
value={listRename.editValue}
onChange={listRename.setEditValue}
onSubmit={listRename.submitRename}
onCancel={listRename.cancelRename}
/>
</span>
) : undefined,
},
model: { label: agent.config.model ?? '—' },
status: { label: agent.isDeployed ? 'Deployed' : 'Draft' },
created: timeCell(agent.createdAt),
owner: ownerCell(agent.createdBy, members),
updated: timeCell(agent.updatedAt),
},
sortValues: {
created: -new Date(agent.createdAt).getTime(),
updated: -new Date(agent.updatedAt).getTime(),
},
}))
}, [agents, deferredSearch, members, listRename.editingId, listRename.editValue])
return (
<>
<Resource
icon={AgentIcon}
title='Agents'
create={{
label: 'New agent',
onClick: handleCreateAgent,
disabled: userPermissions.canEdit !== true || isCreating,
}}
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: 'Search agents…',
}}
defaultSort='created'
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}
onRowContextMenu={handleRowContextMenu}
isLoading={isLoading}
onContextMenu={handleContentContextMenu}
/>
<AgentListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
onClose={closeListContextMenu}
onAddAgent={handleCreateAgent}
disableAdd={userPermissions.canEdit !== true || isCreating}
/>
{activeAgentId && (
<AgentContextMenu
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
onClose={closeRowContextMenu}
onOpen={() => {
router.push(`/workspace/${workspaceId}/agents/${activeAgentId}`)
closeRowContextMenu()
}}
onRename={handleRename}
onDelete={handleDelete}
isDeleting={isDeleting}
disableEdit={userPermissions.canEdit !== true}
/>
)}
</>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Loader, Pencil, SquareArrowUpRight, Trash } from '@/components/emcn/icons'
interface AgentContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpen?: () => void
onRename?: () => void
onDelete?: () => void
isDeleting?: boolean
disableEdit?: boolean
}
export function AgentContextMenu({
isOpen,
position,
onClose,
onOpen,
onRename,
onDelete,
isDeleting = false,
disableEdit = false,
}: AgentContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onOpen && (
<DropdownMenuItem onSelect={onOpen}>
<SquareArrowUpRight />
Open
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem disabled={disableEdit} onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{onDelete && (onOpen || onRename) && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableEdit || isDeleting} onSelect={onDelete}>
{isDeleting ? <Loader className='animate-spin' /> : <Trash />}
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Plus } from '@/components/emcn/icons'
interface AgentListContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onAddAgent?: () => void
disableAdd?: boolean
}
export function AgentListContextMenu({
isOpen,
position,
onClose,
onAddAgent,
disableAdd = false,
}: AgentListContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onAddAgent && (
<DropdownMenuItem disabled={disableAdd} onSelect={onAddAgent}>
<Plus />
New agent
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,3 @@
export default function AgentsLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
}

View File

@@ -0,0 +1,63 @@
import { Skeleton } from '@/components/emcn'
const SKELETON_ROW_COUNT = 5
const COLUMN_COUNT = 6
export default function AgentsLoading() {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
<Skeleton className='h-[14px] w-[96px] rounded-[4px]' />
</div>
<div className='flex items-center gap-[6px]'>
<Skeleton className='h-[28px] w-[80px] rounded-[6px]' />
</div>
</div>
</div>
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
<div className='flex items-center'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
<Skeleton className='ml-[10px] h-[14px] w-[160px] rounded-[4px]' />
</div>
</div>
<div className='min-h-0 flex-1 overflow-auto'>
<table className='w-full'>
<thead>
<tr className='border-[var(--border)] border-b'>
<th className='w-[40px] px-[12px] py-[8px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
</th>
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
<th key={i} className='px-[12px] py-[8px] text-left'>
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
<tr key={rowIndex} className='border-[var(--border)] border-b'>
<td className='w-[40px] px-[12px] py-[10px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
</td>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<td key={colIndex} className='px-[12px] py-[10px]'>
<Skeleton
className='h-[14px] rounded-[4px]'
style={{ width: colIndex === 0 ? '128px' : '80px' }}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { Agents } from '@/app/workspace/[workspaceId]/agents/agents'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export const metadata: Metadata = {
title: 'Agents',
}
interface AgentsPageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function AgentsPage({ params }: AgentsPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideAgentsTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Agents />
}

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