mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
Compare commits
59 Commits
improvemen
...
v0.6.28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f508e445f | ||
|
|
e2d4d0edfe | ||
|
|
cd3cb871be | ||
|
|
762fbbd3e2 | ||
|
|
c89a95d606 | ||
|
|
837233292b | ||
|
|
04434ddf68 | ||
|
|
24af61dcbb | ||
|
|
5c7b057599 | ||
|
|
ad100fa871 | ||
|
|
0b439ecda6 | ||
|
|
d5bea5f266 | ||
|
|
f46886e6cf | ||
|
|
ed19fed0ca | ||
|
|
316bc8cdcc | ||
|
|
68df7320bd | ||
|
|
779358388d | ||
|
|
1e00a06e86 | ||
|
|
8e11c32965 | ||
|
|
64c6cd973f | ||
|
|
89ae738745 | ||
|
|
609ba619bc | ||
|
|
c52834b16c | ||
|
|
cc8c9e88ff | ||
|
|
606477e4e1 | ||
|
|
5eb494de0c | ||
|
|
8df3f207d6 | ||
|
|
df2c47af66 | ||
|
|
25b4a3ff22 | ||
|
|
8c8c6277b9 | ||
|
|
2164cef728 | ||
|
|
58571fe73d | ||
|
|
7e0794c9a0 | ||
|
|
5ea63f1607 | ||
|
|
18a7868bb3 | ||
|
|
cd5cee3033 | ||
|
|
8b1d749f5c | ||
|
|
c18f02384a | ||
|
|
21e5b5c594 | ||
|
|
7ea06931c8 | ||
|
|
590f37641c | ||
|
|
62ea0f1d41 | ||
|
|
796384a0dc | ||
|
|
62a7700eb9 | ||
|
|
c9b45f4f28 | ||
|
|
925be3d635 | ||
|
|
5ca66c381b | ||
|
|
d889f32697 | ||
|
|
7f1efcc798 | ||
|
|
a680cec78f | ||
|
|
235f0748ca | ||
|
|
ebc19484f2 | ||
|
|
33e6921954 | ||
|
|
adfcb67dc2 | ||
|
|
f9a7c4538e | ||
|
|
28af223a9f | ||
|
|
d0baf5b1df | ||
|
|
a54dcbe949 | ||
|
|
0b9019d9a2 |
@@ -19,7 +19,7 @@ When the user asks you to create a block:
|
||||
```typescript
|
||||
import { {ServiceName}Icon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
|
||||
export const {ServiceName}Block: BlockConfig = {
|
||||
@@ -29,6 +29,8 @@ export const {ServiceName}Block: BlockConfig = {
|
||||
longDescription: 'Detailed description for docs',
|
||||
docsLink: 'https://docs.sim.ai/tools/{service}',
|
||||
category: 'tools', // 'tools' | 'blocks' | 'triggers'
|
||||
integrationType: IntegrationType.X, // Primary category (see IntegrationType enum)
|
||||
tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type)
|
||||
bgColor: '#HEXCOLOR', // Brand color
|
||||
icon: {ServiceName}Icon,
|
||||
|
||||
@@ -629,7 +631,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
```typescript
|
||||
import { ServiceIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
|
||||
export const ServiceBlock: BlockConfig = {
|
||||
@@ -639,6 +641,8 @@ export const ServiceBlock: BlockConfig = {
|
||||
longDescription: 'Full description for documentation...',
|
||||
docsLink: 'https://docs.sim.ai/tools/service',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.DeveloperTools,
|
||||
tags: ['oauth', 'api'],
|
||||
bgColor: '#FF6B6B',
|
||||
icon: ServiceIcon,
|
||||
authMode: AuthMode.OAuth,
|
||||
@@ -796,6 +800,8 @@ All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MU
|
||||
|
||||
## Checklist Before Finishing
|
||||
|
||||
- [ ] `integrationType` is set to the correct `IntegrationType` enum value
|
||||
- [ ] `tags` array includes all applicable `IntegrationTag` values
|
||||
- [ ] 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
|
||||
|
||||
@@ -266,9 +266,9 @@ export * from './types'
|
||||
|
||||
## Registering Tools
|
||||
|
||||
After creating tools, remind the user to:
|
||||
After creating tools:
|
||||
1. Import tools in `apps/sim/tools/registry.ts`
|
||||
2. Add to the `tools` object with snake_case keys:
|
||||
2. Add to the `tools` object with snake_case keys (alphabetically):
|
||||
```typescript
|
||||
import { serviceActionTool } from '@/tools/{service}'
|
||||
|
||||
@@ -278,6 +278,130 @@ export const tools = {
|
||||
}
|
||||
```
|
||||
|
||||
## Wiring Tools into the Block (Required)
|
||||
|
||||
After registering in `tools/registry.ts`, you MUST also update the block definition at `apps/sim/blocks/blocks/{service}.ts`. This is not optional — tools are only usable from the UI if they are wired into the block.
|
||||
|
||||
### 1. Add to `tools.access`
|
||||
|
||||
```typescript
|
||||
tools: {
|
||||
access: [
|
||||
// existing tools...
|
||||
'service_new_action', // Add every new tool ID here
|
||||
],
|
||||
config: { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add operation dropdown options
|
||||
|
||||
If the block uses an operation dropdown, add an option for each new tool:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
// existing options...
|
||||
{ label: 'New Action', id: 'new_action' }, // id maps to what tools.config.tool returns
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add subBlocks for new tool params
|
||||
|
||||
For each new tool, add subBlocks covering all its required params (and optional ones where useful). Apply `condition` to show them only for the right operation, and mark required params with `required`:
|
||||
|
||||
```typescript
|
||||
// Required param for new_action
|
||||
{
|
||||
id: 'someParam',
|
||||
title: 'Some Param',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., value',
|
||||
condition: { field: 'operation', value: 'new_action' },
|
||||
required: { field: 'operation', value: 'new_action' },
|
||||
},
|
||||
// Optional param — put in advanced mode
|
||||
{
|
||||
id: 'optionalParam',
|
||||
title: 'Optional Param',
|
||||
type: 'short-input',
|
||||
condition: { field: 'operation', value: 'new_action' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
```
|
||||
|
||||
### 4. Update `tools.config.tool`
|
||||
|
||||
Ensure the tool selector returns the correct tool ID for every new operation. The simplest pattern:
|
||||
|
||||
```typescript
|
||||
tool: (params) => `service_${params.operation}`,
|
||||
// If operation dropdown IDs already match tool IDs, this requires no change.
|
||||
```
|
||||
|
||||
If the dropdown IDs differ from tool IDs, add explicit mappings:
|
||||
|
||||
```typescript
|
||||
tool: (params) => {
|
||||
const map: Record<string, string> = {
|
||||
new_action: 'service_new_action',
|
||||
// ...
|
||||
}
|
||||
return map[params.operation] ?? `service_${params.operation}`
|
||||
},
|
||||
```
|
||||
|
||||
### 5. Update `tools.config.params`
|
||||
|
||||
Add any type coercions needed for new params (runs at execution time, after variable resolution):
|
||||
|
||||
```typescript
|
||||
params: (params) => {
|
||||
const result: Record<string, unknown> = {}
|
||||
if (params.limit != null && params.limit !== '') result.limit = Number(params.limit)
|
||||
if (params.newParamName) result.toolParamName = params.newParamName // rename if IDs differ
|
||||
return result
|
||||
},
|
||||
```
|
||||
|
||||
### 6. Add new outputs
|
||||
|
||||
Add any new fields returned by the new tools to the block `outputs`:
|
||||
|
||||
```typescript
|
||||
outputs: {
|
||||
// existing outputs...
|
||||
newField: { type: 'string', description: 'Description of new field' },
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Add new inputs
|
||||
|
||||
Add new subBlock param IDs to the block `inputs` section:
|
||||
|
||||
```typescript
|
||||
inputs: {
|
||||
// existing inputs...
|
||||
someParam: { type: 'string', description: 'Param description' },
|
||||
optionalParam: { type: 'string', description: 'Optional param description' },
|
||||
}
|
||||
```
|
||||
|
||||
### Block wiring checklist
|
||||
|
||||
- [ ] New tool IDs added to `tools.access`
|
||||
- [ ] Operation dropdown has an option for each new tool
|
||||
- [ ] SubBlocks cover all required params for each new tool
|
||||
- [ ] SubBlocks have correct `condition` (only show for the right operation)
|
||||
- [ ] Optional/rarely-used params set to `mode: 'advanced'`
|
||||
- [ ] `tools.config.tool` returns correct ID for every new operation
|
||||
- [ ] `tools.config.params` handles any ID remapping or type coercions
|
||||
- [ ] New outputs added to block `outputs`
|
||||
- [ ] New params added to block `inputs`
|
||||
|
||||
## V2 Tool Pattern
|
||||
|
||||
If creating V2 tools (API-aligned outputs), use `_v2` suffix:
|
||||
@@ -299,7 +423,9 @@ All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`
|
||||
- [ ] All optional outputs have `optional: true`
|
||||
- [ ] No raw JSON dumps in outputs
|
||||
- [ ] Types file has all interfaces
|
||||
- [ ] Index.ts exports all tools
|
||||
- [ ] Index.ts exports all tools and re-exports types (`export * from './types'`)
|
||||
- [ ] Tools registered in `tools/registry.ts`
|
||||
- [ ] Block wired: `tools.access`, dropdown options, subBlocks, `tools.config`, outputs, inputs
|
||||
|
||||
## Final Validation (Required)
|
||||
|
||||
|
||||
@@ -3,63 +3,57 @@ 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
|
||||
# Add Trigger
|
||||
|
||||
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
|
||||
3. Create a provider handler if custom auth, formatting, or subscriptions are needed
|
||||
4. 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)
|
||||
├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs)
|
||||
├── {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")
|
||||
|
||||
apps/sim/lib/webhooks/
|
||||
├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl)
|
||||
├── providers/
|
||||
│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions)
|
||||
│ ├── types.ts # WebhookProviderHandler interface
|
||||
│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes)
|
||||
│ └── registry.ts # Handler map + default handler
|
||||
```
|
||||
|
||||
## Step 1: Create utils.ts
|
||||
## Step 1: Create `utils.ts`
|
||||
|
||||
This file contains service-specific helpers used by all triggers.
|
||||
This file contains all service-specific helpers used by 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',
|
||||
'Paste the webhook URL and save',
|
||||
'Click "Save" above to activate your trigger',
|
||||
]
|
||||
|
||||
return instructions
|
||||
.map((instruction, index) =>
|
||||
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
|
||||
@@ -67,10 +61,6 @@ export function {service}SetupInstructions(eventType: string): string {
|
||||
.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 [
|
||||
{
|
||||
@@ -78,53 +68,34 @@ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
|
||||
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' },
|
||||
eventType: { type: 'string', description: 'The type of event' },
|
||||
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
|
||||
## Step 2: Create Trigger Files
|
||||
|
||||
The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types.
|
||||
**Primary trigger** — MUST include `includeDropdown: true`:
|
||||
|
||||
```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 { 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',
|
||||
@@ -132,496 +103,222 @@ export const {service}EventATrigger: TriggerConfig = {
|
||||
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
|
||||
includeDropdown: true,
|
||||
setupInstructions: {service}SetupInstructions('Event A'),
|
||||
extraFields: build{Service}ExtraFields('{service}_event_a'),
|
||||
}),
|
||||
|
||||
outputs: build{Service}Outputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
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).
|
||||
**Secondary triggers** — NO `includeDropdown` (it's already in the primary):
|
||||
|
||||
```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',
|
||||
},
|
||||
},
|
||||
// Same as above but: id: '{service}_event_b', no includeDropdown
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Create index.ts Barrel Export
|
||||
## Step 3: Register and Wire
|
||||
|
||||
### `apps/sim/triggers/{service}/index.ts`
|
||||
|
||||
```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`)
|
||||
### `apps/sim/triggers/registry.ts`
|
||||
|
||||
```typescript
|
||||
// Add import
|
||||
import {
|
||||
{service}EventATrigger,
|
||||
{service}EventBTrigger,
|
||||
{service}EventCTrigger,
|
||||
{service}WebhookTrigger,
|
||||
} from '@/triggers/{service}'
|
||||
import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}'
|
||||
|
||||
// Add to TRIGGER_REGISTRY
|
||||
export const TRIGGER_REGISTRY: TriggerRegistry = {
|
||||
// ... existing triggers ...
|
||||
// ... existing ...
|
||||
{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`):
|
||||
### 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',
|
||||
],
|
||||
available: ['{service}_event_a', '{service}_event_b'],
|
||||
},
|
||||
|
||||
subBlocks: [
|
||||
// Regular tool subBlocks first
|
||||
{ id: 'operation', /* ... */ },
|
||||
{ id: 'credential', /* ... */ },
|
||||
// ... other tool fields ...
|
||||
|
||||
// Then spread ALL trigger subBlocks
|
||||
// Regular tool subBlocks first...
|
||||
...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)
|
||||
## Provider Handler
|
||||
|
||||
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.
|
||||
All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`.
|
||||
|
||||
### When to Use Automatic Registration
|
||||
### When to Create a Handler
|
||||
|
||||
Check the service's API documentation for endpoints like:
|
||||
- `POST /webhooks` or `POST /hooks` - Create webhook
|
||||
- `DELETE /webhooks/{id}` - Delete webhook
|
||||
| Behavior | Method | Examples |
|
||||
|---|---|---|
|
||||
| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform |
|
||||
| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms |
|
||||
| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot |
|
||||
| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira |
|
||||
| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby |
|
||||
| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable |
|
||||
| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable |
|
||||
| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams |
|
||||
| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams |
|
||||
|
||||
Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc.
|
||||
If none apply, you don't need a handler. The default handler provides bearer token auth.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
#### 1. Add API Key to Extra Fields
|
||||
|
||||
Update your `build{Service}ExtraFields` function to include an API key field:
|
||||
### Example Handler
|
||||
|
||||
```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 },
|
||||
},
|
||||
]
|
||||
import crypto from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import type { EventMatchContext, FormatInputContext, FormatInputResult, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:{Service}')
|
||||
|
||||
function validate{Service}Signature(secret: string, signature: string, body: string): boolean {
|
||||
if (!secret || !signature || !body) return false
|
||||
const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
|
||||
return safeCompare(computed, signature)
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Update Setup Instructions for Automatic Creation
|
||||
export const {service}Handler: WebhookProviderHandler = {
|
||||
verifyAuth: createHmacVerifier({
|
||||
configKey: 'webhookSecret',
|
||||
headerName: 'X-{Service}-Signature',
|
||||
validateFn: validate{Service}Signature,
|
||||
providerLabel: '{Service}',
|
||||
}),
|
||||
|
||||
Change instructions to indicate automatic webhook creation:
|
||||
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
|
||||
const triggerId = providerConfig.triggerId as string | undefined
|
||||
if (triggerId && triggerId !== '{service}_webhook') {
|
||||
const { is{Service}EventMatch } = await import('@/triggers/{service}/utils')
|
||||
if (!is{Service}EventMatch(triggerId, body as Record<string, unknown>)) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
```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,
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
return {
|
||||
input: {
|
||||
eventType: b.type,
|
||||
resourceId: (b.data as Record<string, unknown>)?.id || '',
|
||||
resource: b.data,
|
||||
},
|
||||
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 }
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
extractIdempotencyId(body: unknown) {
|
||||
const obj = body as Record<string, unknown>
|
||||
return obj.id && obj.type ? `${obj.type}:${obj.id}` : null
|
||||
},
|
||||
}
|
||||
// --- End {Service} specific logic ---
|
||||
```
|
||||
|
||||
Then add the helper function at the end of the file:
|
||||
### Register the Handler
|
||||
|
||||
In `apps/sim/lib/webhooks/providers/registry.ts`:
|
||||
|
||||
```typescript
|
||||
async function create{Service}WebhookSubscription(
|
||||
webhookData: any,
|
||||
requestId: string
|
||||
): Promise<{ id: string } | undefined> {
|
||||
try {
|
||||
const { path, providerConfig } = webhookData
|
||||
const { apiKey, triggerId, projectId } = providerConfig || {}
|
||||
import { {service}Handler } from '@/lib/webhooks/providers/{service}'
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('{Service} API Key is required.')
|
||||
}
|
||||
const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
|
||||
// ... existing (alphabetical) ...
|
||||
{service}: {service}Handler,
|
||||
}
|
||||
```
|
||||
|
||||
// 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
|
||||
}
|
||||
## Output Alignment (Critical)
|
||||
|
||||
const eventType = eventTypeMap[triggerId]
|
||||
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
|
||||
There are two sources of truth that **MUST be aligned**:
|
||||
|
||||
const requestBody: Record<string, any> = {
|
||||
url: notificationUrl,
|
||||
}
|
||||
1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown)
|
||||
2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data
|
||||
|
||||
if (eventType) {
|
||||
requestBody.eventType = eventType
|
||||
}
|
||||
If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover.
|
||||
|
||||
if (projectId) {
|
||||
requestBody.projectId = projectId
|
||||
}
|
||||
**Rules for `formatInput`:**
|
||||
- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly
|
||||
- Return `{ input: ..., skip: { message: '...' } }` to skip execution
|
||||
- No wrapper objects or duplication
|
||||
- Use `null` for missing optional data
|
||||
|
||||
const response = await fetch('https://api.{service}.com/webhooks', {
|
||||
## Automatic Webhook Registration
|
||||
|
||||
If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**.
|
||||
|
||||
```typescript
|
||||
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
|
||||
import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types'
|
||||
|
||||
export const {service}Handler: WebhookProviderHandler = {
|
||||
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
|
||||
const config = getProviderConfig(ctx.webhook)
|
||||
const apiKey = config.apiKey as string
|
||||
if (!apiKey) throw new Error('{Service} API Key is required.')
|
||||
|
||||
const res = await fetch('https://api.{service}.com/webhooks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }),
|
||||
})
|
||||
|
||||
const responseBody = await response.json()
|
||||
if (!res.ok) throw new Error(`{Service} error: ${res.status}`)
|
||||
const { id } = (await res.json()) as { id: string }
|
||||
return { providerConfigUpdates: { externalId: id } }
|
||||
},
|
||||
|
||||
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}`, {
|
||||
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
|
||||
const config = getProviderConfig(ctx.webhook)
|
||||
const { apiKey, externalId } = config as { apiKey?: string; externalId?: string }
|
||||
if (!apiKey || !externalId) return
|
||||
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)
|
||||
}
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}).catch(() => {})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
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:**
|
||||
- Throw from `createSubscription` — orchestration rolls back the DB webhook
|
||||
- Never throw from `deleteSubscription` — log non-fatally
|
||||
- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig`
|
||||
- Add `apiKey` field to `build{Service}ExtraFields` with `password: true`
|
||||
|
||||
### 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 Schema
|
||||
|
||||
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)
|
||||
**Supported:** `type` + `description` for leaf fields, nested objects for complex data.
|
||||
**NOT supported:** `optional: true`, `items` (those are tool-output-only features).
|
||||
|
||||
```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' },
|
||||
@@ -630,79 +327,32 @@ export function buildOutputs(): Record<string, TriggerOutput> {
|
||||
}
|
||||
```
|
||||
|
||||
## Generic Webhook Trigger Pattern
|
||||
## Checklist
|
||||
|
||||
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`
|
||||
### Trigger Definition
|
||||
- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders
|
||||
- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT
|
||||
- [ ] 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`
|
||||
- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY`
|
||||
- [ ] Block has `triggers.enabled: true` and lists 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
|
||||
### Provider Handler (if needed)
|
||||
- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts`
|
||||
- [ ] Registered in `providers/registry.ts` (alphabetical)
|
||||
- [ ] Signature validator is a private function inside the handler file
|
||||
- [ ] `formatInput` output keys match trigger `outputs` exactly
|
||||
- [ ] Event matching uses dynamic `await import()` for trigger utils
|
||||
|
||||
### 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
|
||||
### Auto Registration (if supported)
|
||||
- [ ] `createSubscription` and `deleteSubscription` on the handler
|
||||
- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
|
||||
- [ ] API key field uses `password: true`
|
||||
|
||||
### 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)
|
||||
- [ ] `bun run type-check` passes
|
||||
- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys
|
||||
- [ ] Trigger UI shows correctly in the block
|
||||
|
||||
212
.agents/skills/validate-trigger/SKILL.md
Normal file
212
.agents/skills/validate-trigger/SKILL.md
Normal file
@@ -0,0 +1,212 @@
|
||||
---
|
||||
name: validate-trigger
|
||||
description: Audit an existing Sim webhook trigger against the service's webhook API docs and repository conventions, then report and fix issues across trigger definitions, provider handler, output alignment, registration, and security. Use when validating or repairing a trigger under `apps/sim/triggers/{service}/` or `apps/sim/lib/webhooks/providers/{service}.ts`.
|
||||
---
|
||||
|
||||
# Validate Trigger
|
||||
|
||||
You are an expert auditor for Sim webhook triggers. Your job is to validate that an existing trigger implementation is correct, complete, secure, and aligned across all layers.
|
||||
|
||||
## Your Task
|
||||
|
||||
1. Read the service's webhook/API documentation (via WebFetch)
|
||||
2. Read every trigger file, provider handler, and registry entry
|
||||
3. Cross-reference against the API docs and Sim conventions
|
||||
4. Report all issues grouped by severity (critical, warning, suggestion)
|
||||
5. Fix all issues after reporting them
|
||||
|
||||
## Step 1: Gather All Files
|
||||
|
||||
Read **every** file for the trigger — do not skip any:
|
||||
|
||||
```
|
||||
apps/sim/triggers/{service}/ # All trigger files, utils.ts, index.ts
|
||||
apps/sim/lib/webhooks/providers/{service}.ts # Provider handler (if exists)
|
||||
apps/sim/lib/webhooks/providers/registry.ts # Handler registry
|
||||
apps/sim/triggers/registry.ts # Trigger registry
|
||||
apps/sim/blocks/blocks/{service}.ts # Block definition (trigger wiring)
|
||||
```
|
||||
|
||||
Also read for reference:
|
||||
```
|
||||
apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface
|
||||
apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.)
|
||||
apps/sim/lib/webhooks/provider-subscription-utils.ts # Subscription helpers
|
||||
apps/sim/lib/webhooks/processor.ts # Central webhook processor
|
||||
```
|
||||
|
||||
## Step 2: Pull API Documentation
|
||||
|
||||
Fetch the service's official webhook documentation. This is the **source of truth** for:
|
||||
- Webhook event types and payload shapes
|
||||
- Signature/auth verification method (HMAC algorithm, header names, secret format)
|
||||
- Challenge/verification handshake requirements
|
||||
- Webhook subscription API (create/delete endpoints, if applicable)
|
||||
- Retry behavior and delivery guarantees
|
||||
|
||||
## Step 3: Validate Trigger Definitions
|
||||
|
||||
### utils.ts
|
||||
- [ ] `{service}TriggerOptions` lists all trigger IDs accurately
|
||||
- [ ] `{service}SetupInstructions` provides clear, correct steps for the service
|
||||
- [ ] `build{Service}ExtraFields` includes relevant filter/config fields with correct `condition`
|
||||
- [ ] Output builders expose all meaningful fields from the webhook payload
|
||||
- [ ] Output builders do NOT use `optional: true` or `items` (tool-output-only features)
|
||||
- [ ] Nested output objects correctly model the payload structure
|
||||
|
||||
### Trigger Files
|
||||
- [ ] Exactly one primary trigger has `includeDropdown: true`
|
||||
- [ ] All secondary triggers do NOT have `includeDropdown`
|
||||
- [ ] All triggers use `buildTriggerSubBlocks` helper (not hand-rolled subBlocks)
|
||||
- [ ] Every trigger's `id` matches the convention `{service}_{event_name}`
|
||||
- [ ] Every trigger's `provider` matches the service name used in the handler registry
|
||||
- [ ] `index.ts` barrel exports all triggers
|
||||
|
||||
### Trigger ↔ Provider Alignment (CRITICAL)
|
||||
- [ ] Every trigger ID referenced in `matchEvent` logic exists in `{service}TriggerOptions`
|
||||
- [ ] Event matching logic in the provider correctly maps trigger IDs to service event types
|
||||
- [ ] Event matching logic in `is{Service}EventMatch` (if exists) correctly identifies events per the API docs
|
||||
|
||||
## Step 4: Validate Provider Handler
|
||||
|
||||
### Auth Verification
|
||||
- [ ] `verifyAuth` correctly validates webhook signatures per the service's documentation
|
||||
- [ ] HMAC algorithm matches (SHA-1, SHA-256, SHA-512)
|
||||
- [ ] Signature header name matches the API docs exactly
|
||||
- [ ] Signature format is handled (raw hex, `sha256=` prefix, base64, etc.)
|
||||
- [ ] Uses `safeCompare` for timing-safe comparison (no `===`)
|
||||
- [ ] If `webhookSecret` is required, handler rejects when it's missing (fail-closed)
|
||||
- [ ] Signature is computed over raw body (not parsed JSON)
|
||||
|
||||
### Event Matching
|
||||
- [ ] `matchEvent` returns `boolean` (not `NextResponse` or other values)
|
||||
- [ ] Challenge/verification events are excluded from matching (e.g., `endpoint.url_validation`)
|
||||
- [ ] When `triggerId` is a generic webhook ID, all events pass through
|
||||
- [ ] When `triggerId` is specific, only matching events pass
|
||||
- [ ] Event matching logic uses dynamic `await import()` for trigger utils (avoids circular deps)
|
||||
|
||||
### formatInput (CRITICAL)
|
||||
- [ ] Every key in the `formatInput` return matches a key in the trigger `outputs` schema
|
||||
- [ ] Every key in the trigger `outputs` schema is populated by `formatInput`
|
||||
- [ ] No extra undeclared keys that users can't discover in the UI
|
||||
- [ ] No wrapper objects (`webhook: { ... }`, `{service}: { ... }`)
|
||||
- [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`)
|
||||
- [ ] `null` is used for missing optional fields (not empty strings or empty objects)
|
||||
- [ ] Returns `{ input: { ... } }` — not a bare object
|
||||
|
||||
### Idempotency
|
||||
- [ ] `extractIdempotencyId` returns a stable, unique key per delivery
|
||||
- [ ] Uses provider-specific delivery IDs when available (e.g., `X-Request-Id`, `Linear-Delivery`, `svix-id`)
|
||||
- [ ] Falls back to content-based ID (e.g., `${type}:${id}`) when no delivery header exists
|
||||
- [ ] Does NOT include timestamps in the idempotency key (would break dedup on retries)
|
||||
|
||||
### Challenge Handling (if applicable)
|
||||
- [ ] `handleChallenge` correctly implements the service's URL verification handshake
|
||||
- [ ] Returns the expected response format per the API docs
|
||||
- [ ] Env-backed secrets are resolved via `resolveEnvVarsInObject` if needed
|
||||
|
||||
## Step 5: Validate Automatic Subscription Lifecycle
|
||||
|
||||
If the service supports programmatic webhook creation:
|
||||
|
||||
### createSubscription
|
||||
- [ ] Calls the correct API endpoint to create a webhook
|
||||
- [ ] Sends the correct event types/filters
|
||||
- [ ] Passes the notification URL from `getNotificationUrl(ctx.webhook)`
|
||||
- [ ] Returns `{ providerConfigUpdates: { externalId } }` with the external webhook ID
|
||||
- [ ] Throws on failure (orchestration handles rollback)
|
||||
- [ ] Provides user-friendly error messages (401 → "Invalid API Key", etc.)
|
||||
|
||||
### deleteSubscription
|
||||
- [ ] Calls the correct API endpoint to delete the webhook
|
||||
- [ ] Handles 404 gracefully (webhook already deleted)
|
||||
- [ ] Never throws — catches errors and logs non-fatally
|
||||
- [ ] Skips gracefully when `apiKey` or `externalId` is missing
|
||||
|
||||
### Orchestration Isolation
|
||||
- [ ] NO provider-specific logic in `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
|
||||
- [ ] All subscription logic lives on the handler (`createSubscription`/`deleteSubscription`)
|
||||
|
||||
## Step 6: Validate Registration and Block Wiring
|
||||
|
||||
### Trigger Registry (`triggers/registry.ts`)
|
||||
- [ ] All triggers are imported and registered
|
||||
- [ ] Registry keys match trigger IDs exactly
|
||||
- [ ] No orphaned entries (triggers that don't exist)
|
||||
|
||||
### Provider Handler Registry (`providers/registry.ts`)
|
||||
- [ ] Handler is imported and registered (if handler exists)
|
||||
- [ ] Registry key matches the `provider` field on the trigger configs
|
||||
- [ ] Entries are in alphabetical order
|
||||
|
||||
### Block Wiring (`blocks/blocks/{service}.ts`)
|
||||
- [ ] Block has `triggers.enabled: true`
|
||||
- [ ] `triggers.available` lists all trigger IDs
|
||||
- [ ] All trigger subBlocks are spread into `subBlocks`: `...getTrigger('id').subBlocks`
|
||||
- [ ] No trigger IDs in `triggers.available` that aren't in the registry
|
||||
- [ ] No trigger subBlocks spread that aren't in `triggers.available`
|
||||
|
||||
## Step 7: Validate Security
|
||||
|
||||
- [ ] Webhook secrets are never logged (not even at debug level)
|
||||
- [ ] Auth verification runs before any event processing
|
||||
- [ ] No secret comparison uses `===` (must use `safeCompare` or `crypto.timingSafeEqual`)
|
||||
- [ ] Timestamp/replay protection is reasonable (not too tight for retries, not too loose for security)
|
||||
- [ ] Raw body is used for signature verification (not re-serialized JSON)
|
||||
|
||||
## Step 8: Report and Fix
|
||||
|
||||
### Report Format
|
||||
|
||||
Group findings by severity:
|
||||
|
||||
**Critical** (runtime errors, security issues, or data loss):
|
||||
- Wrong HMAC algorithm or header name
|
||||
- `formatInput` keys don't match trigger `outputs`
|
||||
- Missing `verifyAuth` when the service sends signed webhooks
|
||||
- `matchEvent` returns non-boolean values
|
||||
- Provider-specific logic leaking into shared orchestration files
|
||||
- Trigger IDs mismatch between trigger files, registry, and block
|
||||
- `createSubscription` calling wrong API endpoint
|
||||
- Auth comparison using `===` instead of `safeCompare`
|
||||
|
||||
**Warning** (convention violations or usability issues):
|
||||
- Missing `extractIdempotencyId` when the service provides delivery IDs
|
||||
- Timestamps in idempotency keys (breaks dedup on retries)
|
||||
- Missing challenge handling when the service requires URL verification
|
||||
- Output schema missing fields that `formatInput` returns (undiscoverable data)
|
||||
- Overly tight timestamp skew window that rejects legitimate retries
|
||||
- `matchEvent` not filtering challenge/verification events
|
||||
- Setup instructions missing important steps
|
||||
|
||||
**Suggestion** (minor improvements):
|
||||
- More specific output field descriptions
|
||||
- Additional output fields that could be exposed
|
||||
- Better error messages in `createSubscription`
|
||||
- Logging improvements
|
||||
|
||||
### 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 type-check` passes
|
||||
2. Re-read all modified files to verify fixes are correct
|
||||
3. Provider handler tests pass (if they exist): `bun test {service}`
|
||||
|
||||
## Checklist Summary
|
||||
|
||||
- [ ] Read all trigger files, provider handler, types, registries, and block
|
||||
- [ ] Pulled and read official webhook/API documentation
|
||||
- [ ] Validated trigger definitions: options, instructions, extra fields, outputs
|
||||
- [ ] Validated primary/secondary trigger distinction (`includeDropdown`)
|
||||
- [ ] Validated provider handler: auth, matchEvent, formatInput, idempotency
|
||||
- [ ] Validated output alignment: every `outputs` key ↔ every `formatInput` key
|
||||
- [ ] Validated subscription lifecycle: createSubscription, deleteSubscription, no shared-file edits
|
||||
- [ ] Validated registration: trigger registry, handler registry, block wiring
|
||||
- [ ] Validated security: safe comparison, no secret logging, replay protection
|
||||
- [ ] Reported all issues grouped by severity
|
||||
- [ ] Fixed all critical and warning issues
|
||||
- [ ] `bun run type-check` passes after fixes
|
||||
17
.agents/skills/you-might-not-need-an-effect/SKILL.md
Normal file
17
.agents/skills/you-might-not-need-an-effect/SKILL.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: you-might-not-need-an-effect
|
||||
description: Analyze and fix useEffect anti-patterns in your code
|
||||
---
|
||||
|
||||
# You Might Not Need an Effect
|
||||
|
||||
Arguments:
|
||||
- scope: what to analyze (default: your current changes). Examples: "diff to main", "PR #123", "src/components/", "whole codebase"
|
||||
- fix: whether to apply fixes (default: true). Set to false to only propose changes.
|
||||
|
||||
User arguments: $ARGUMENTS
|
||||
|
||||
Steps:
|
||||
1. Read https://react.dev/learn/you-might-not-need-an-effect to understand the guidelines
|
||||
2. Analyze the specified scope for useEffect anti-patterns
|
||||
3. If fix=true, apply the fixes. If fix=false, propose the fixes without applying.
|
||||
@@ -71,12 +71,14 @@ export const {service}Connector: ConnectorConfig = {
|
||||
],
|
||||
|
||||
listDocuments: async (accessToken, sourceConfig, cursor) => {
|
||||
// Paginate via cursor, extract text, compute SHA-256 hash
|
||||
// Return metadata stubs with contentDeferred: true (if per-doc content fetch needed)
|
||||
// Or full documents with content (if list API returns content inline)
|
||||
// Return { documents: ExternalDocument[], nextCursor?, hasMore }
|
||||
},
|
||||
|
||||
getDocument: async (accessToken, sourceConfig, externalId) => {
|
||||
// Return ExternalDocument or null
|
||||
// Fetch full content for a single document
|
||||
// Return ExternalDocument with contentDeferred: false, or null
|
||||
},
|
||||
|
||||
validateConfig: async (accessToken, sourceConfig) => {
|
||||
@@ -281,26 +283,110 @@ Every document returned from `listDocuments`/`getDocument` must include:
|
||||
{
|
||||
externalId: string // Source-specific unique ID
|
||||
title: string // Document title
|
||||
content: string // Extracted plain text
|
||||
content: string // Extracted plain text (or '' if contentDeferred)
|
||||
contentDeferred?: boolean // true = content will be fetched via getDocument
|
||||
mimeType: 'text/plain' // Always text/plain (content is extracted)
|
||||
contentHash: string // SHA-256 of content (change detection)
|
||||
contentHash: string // Metadata-based hash for 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)
|
||||
## Content Deferral (Required for file/content-download connectors)
|
||||
|
||||
The sync engine uses content hashes for change detection:
|
||||
**All connectors that require per-document API calls to fetch content MUST use `contentDeferred: true`.** This is the standard pattern — `listDocuments` returns lightweight metadata stubs, and content is fetched lazily by the sync engine via `getDocument` only for new/changed documents.
|
||||
|
||||
This pattern is critical for reliability: the sync engine processes documents in batches and enqueues each batch for processing immediately. If a sync times out, all previously-batched documents are already queued. Without deferral, content downloads during listing can exhaust the sync task's time budget before any documents are saved.
|
||||
|
||||
### When to use `contentDeferred: true`
|
||||
|
||||
- The service's list API does NOT return document content (only metadata)
|
||||
- Content requires a separate download/export API call per document
|
||||
- Examples: Google Drive, OneDrive, SharePoint, Dropbox, Notion, Confluence, Gmail, Obsidian, Evernote, GitHub
|
||||
|
||||
### When NOT to use `contentDeferred`
|
||||
|
||||
- The list API already returns the full content inline (e.g., Slack messages, Reddit posts, HubSpot notes)
|
||||
- No per-document API call is needed to get content
|
||||
|
||||
### Content Hash Strategy
|
||||
|
||||
Use a **metadata-based** `contentHash` — never a content-based hash. The hash must be derivable from the list response metadata alone, so the sync engine can detect changes without downloading content.
|
||||
|
||||
Good metadata hash sources:
|
||||
- `modifiedTime` / `lastModifiedDateTime` — changes when file is edited
|
||||
- Git blob SHA — unique per content version
|
||||
- API-provided content hash (e.g., Dropbox `content_hash`)
|
||||
- Version number (e.g., Confluence page version)
|
||||
|
||||
Format: `{service}:{id}:{changeIndicator}`
|
||||
|
||||
```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('')
|
||||
// Google Drive: modifiedTime changes on edit
|
||||
contentHash: `gdrive:${file.id}:${file.modifiedTime ?? ''}`
|
||||
|
||||
// GitHub: blob SHA is a content-addressable hash
|
||||
contentHash: `gitsha:${item.sha}`
|
||||
|
||||
// Dropbox: API provides content_hash
|
||||
contentHash: `dropbox:${entry.id}:${entry.content_hash ?? entry.server_modified}`
|
||||
|
||||
// Confluence: version number increments on edit
|
||||
contentHash: `confluence:${page.id}:${page.version.number}`
|
||||
```
|
||||
|
||||
**Critical invariant:** The `contentHash` MUST be identical whether produced by `listDocuments` (stub) or `getDocument` (full doc). Both should use the same stub function to guarantee this.
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
```typescript
|
||||
// 1. Create a stub function (sync, no API calls)
|
||||
function fileToStub(file: ServiceFile): ExternalDocument {
|
||||
return {
|
||||
externalId: file.id,
|
||||
title: file.name || 'Untitled',
|
||||
content: '',
|
||||
contentDeferred: true,
|
||||
mimeType: 'text/plain',
|
||||
sourceUrl: `https://service.com/file/${file.id}`,
|
||||
contentHash: `service:${file.id}:${file.modifiedTime ?? ''}`,
|
||||
metadata: { /* fields needed by mapTags */ },
|
||||
}
|
||||
}
|
||||
|
||||
// 2. listDocuments returns stubs (fast, metadata only)
|
||||
listDocuments: async (accessToken, sourceConfig, cursor) => {
|
||||
const response = await fetchWithRetry(listUrl, { ... })
|
||||
const files = (await response.json()).files
|
||||
const documents = files.map(fileToStub)
|
||||
return { documents, nextCursor, hasMore }
|
||||
}
|
||||
|
||||
// 3. getDocument fetches content and returns full doc with SAME contentHash
|
||||
getDocument: async (accessToken, sourceConfig, externalId) => {
|
||||
const metadata = await fetchWithRetry(metadataUrl, { ... })
|
||||
const file = await metadata.json()
|
||||
if (file.trashed) return null
|
||||
|
||||
try {
|
||||
const content = await fetchContent(accessToken, file)
|
||||
if (!content.trim()) return null
|
||||
const stub = fileToStub(file)
|
||||
return { ...stub, content, contentDeferred: false }
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch content for: ${file.name}`, { error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reference Implementations
|
||||
|
||||
- **Google Drive**: `connectors/google-drive/google-drive.ts` — file download/export with `modifiedTime` hash
|
||||
- **GitHub**: `connectors/github/github.ts` — git blob SHA hash
|
||||
- **Notion**: `connectors/notion/notion.ts` — blocks API with `last_edited_time` hash
|
||||
- **Confluence**: `connectors/confluence/confluence.ts` — version number hash
|
||||
|
||||
## tagDefinitions — Declared Tag Definitions
|
||||
|
||||
Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes.
|
||||
@@ -409,7 +495,10 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = {
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
- **OAuth**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching
|
||||
- **OAuth + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination
|
||||
- **OAuth + contentDeferred (blocks API)**: `apps/sim/connectors/notion/notion.ts` — complex block content extraction deferred to `getDocument`
|
||||
- **OAuth + contentDeferred (git)**: `apps/sim/connectors/github/github.ts` — blob SHA hash, tree listing
|
||||
- **OAuth + inline content**: `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
|
||||
@@ -425,7 +514,9 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = {
|
||||
- `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
|
||||
- [ ] `listDocuments` handles pagination with metadata-based content hashes
|
||||
- [ ] `contentDeferred: true` used if content requires per-doc API calls (file download, export, blocks fetch)
|
||||
- [ ] `contentHash` is metadata-based (not content-based) and identical between stub and `getDocument`
|
||||
- [ ] `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`
|
||||
|
||||
296
.claude/commands/add-hosted-key.md
Normal file
296
.claude/commands/add-hosted-key.md
Normal file
@@ -0,0 +1,296 @@
|
||||
---
|
||||
description: Add hosted API key support to a tool so Sim provides the key when users don't bring their own
|
||||
argument-hint: <service-name>
|
||||
---
|
||||
|
||||
# Adding Hosted Key Support to a Tool
|
||||
|
||||
When a tool has hosted key support, Sim provides its own API key if the user hasn't configured one (via BYOK or env var). Usage is metered and billed to the workspace.
|
||||
|
||||
## Overview
|
||||
|
||||
| Step | What | Where |
|
||||
|------|------|-------|
|
||||
| 1 | Register BYOK provider ID | `tools/types.ts`, `app/api/workspaces/[id]/byok-keys/route.ts` |
|
||||
| 2 | Research the API's pricing and rate limits | API docs / pricing page (before writing any code) |
|
||||
| 3 | Add `hosting` config to the tool | `tools/{service}/{action}.ts` |
|
||||
| 4 | Hide API key field when hosted | `blocks/blocks/{service}.ts` |
|
||||
| 5 | Add to BYOK settings UI | BYOK settings component (`byok.tsx`) |
|
||||
| 6 | Summarize pricing and throttling comparison | Output to user (after all code changes) |
|
||||
|
||||
## Step 1: Register the BYOK Provider ID
|
||||
|
||||
Add the new provider to the `BYOKProviderId` union in `tools/types.ts`:
|
||||
|
||||
```typescript
|
||||
export type BYOKProviderId =
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
// ...existing providers
|
||||
| 'your_service'
|
||||
```
|
||||
|
||||
Then add it to `VALID_PROVIDERS` in `app/api/workspaces/[id]/byok-keys/route.ts`:
|
||||
|
||||
```typescript
|
||||
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'your_service'] as const
|
||||
```
|
||||
|
||||
## Step 2: Research the API's Pricing Model and Rate Limits
|
||||
|
||||
**Before writing any `getCost` or `rateLimit` code**, look up the service's official documentation for both pricing and rate limits. You need to understand:
|
||||
|
||||
### Pricing
|
||||
|
||||
1. **How the API charges** — per request, per credit, per token, per step, per minute, etc.
|
||||
2. **Whether the API reports cost in its response** — look for fields like `creditsUsed`, `costDollars`, `tokensUsed`, or similar in the response body or headers
|
||||
3. **Whether cost varies by endpoint/options** — some APIs charge more for certain features (e.g., Firecrawl charges 1 credit/page base but +4 for JSON format, +4 for enhanced mode)
|
||||
4. **The dollar-per-unit rate** — what each credit/token/unit costs in dollars on our plan
|
||||
|
||||
### Rate Limits
|
||||
|
||||
1. **What rate limits the API enforces** — requests per minute/second, tokens per minute, concurrent requests, etc.
|
||||
2. **Whether limits vary by plan tier** — free vs paid vs enterprise often have different ceilings
|
||||
3. **Whether limits are per-key or per-account** — determines whether adding more hosted keys actually increases total throughput
|
||||
4. **What the API returns when rate limited** — HTTP 429, `Retry-After` header, error body format, etc.
|
||||
5. **Whether there are multiple dimensions** — some APIs limit both requests/min AND tokens/min independently
|
||||
|
||||
Search the API's docs/pricing page (use WebSearch/WebFetch). Capture the pricing model as a comment in `getCost` so future maintainers know the source of truth.
|
||||
|
||||
### Setting Our Rate Limits
|
||||
|
||||
Our rate limiter (`lib/core/rate-limiter/hosted-key/`) uses a token-bucket algorithm applied **per billing actor** (workspace). It supports two modes:
|
||||
|
||||
- **`per_request`** — simple; just `requestsPerMinute`. Good when the API charges flat per-request or cost doesn't vary much.
|
||||
- **`custom`** — `requestsPerMinute` plus additional `dimensions` (e.g., `tokens`, `search_units`). Each dimension has its own `limitPerMinute` and an `extractUsage` function that reads actual usage from the response. Use when the API charges on a variable metric (tokens, credits) and you want to cap that metric too.
|
||||
|
||||
When choosing values for `requestsPerMinute` and any dimension limits:
|
||||
|
||||
- **Stay well below the API's per-key limit** — our keys are shared across all workspaces. If the API allows 60 RPM per key and we have 3 keys, the global ceiling is ~180 RPM. Set the per-workspace limit low enough (e.g., 20-60 RPM) that many workspaces can coexist without collectively hitting the API's ceiling.
|
||||
- **Account for key pooling** — our round-robin distributes requests across `N` hosted keys, so the effective API-side rate per key is `(total requests) / N`. But per-workspace limits are enforced *before* key selection, so they apply regardless of key count.
|
||||
- **Prefer conservative defaults** — it's easy to raise limits later but hard to claw back after users depend on high throughput.
|
||||
|
||||
## Step 3: Add `hosting` Config to the Tool
|
||||
|
||||
Add a `hosting` object to the tool's `ToolConfig`. This tells the execution layer how to acquire hosted keys, calculate cost, and rate-limit.
|
||||
|
||||
```typescript
|
||||
hosting: {
|
||||
envKeyPrefix: 'YOUR_SERVICE_API_KEY',
|
||||
apiKeyParam: 'apiKey',
|
||||
byokProviderId: 'your_service',
|
||||
pricing: {
|
||||
type: 'custom',
|
||||
getCost: (_params, output) => {
|
||||
if (output.creditsUsed == null) {
|
||||
throw new Error('Response missing creditsUsed field')
|
||||
}
|
||||
const creditsUsed = output.creditsUsed as number
|
||||
const cost = creditsUsed * 0.001 // dollars per credit
|
||||
return { cost, metadata: { creditsUsed } }
|
||||
},
|
||||
},
|
||||
rateLimit: {
|
||||
mode: 'per_request',
|
||||
requestsPerMinute: 100,
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Hosted Key Env Var Convention
|
||||
|
||||
Keys use a numbered naming pattern driven by a count env var:
|
||||
|
||||
```
|
||||
YOUR_SERVICE_API_KEY_COUNT=3
|
||||
YOUR_SERVICE_API_KEY_1=sk-...
|
||||
YOUR_SERVICE_API_KEY_2=sk-...
|
||||
YOUR_SERVICE_API_KEY_3=sk-...
|
||||
```
|
||||
|
||||
The `envKeyPrefix` value (`YOUR_SERVICE_API_KEY`) determines which env vars are read at runtime. Adding more keys only requires bumping the count and adding the new env var.
|
||||
|
||||
### Pricing: Prefer API-Reported Cost
|
||||
|
||||
Always prefer using cost data returned by the API (e.g., `creditsUsed`, `costDollars`). This is the most accurate because it accounts for variable pricing tiers, feature modifiers, and plan-level discounts.
|
||||
|
||||
**When the API reports cost** — use it directly and throw if missing:
|
||||
|
||||
```typescript
|
||||
pricing: {
|
||||
type: 'custom',
|
||||
getCost: (params, output) => {
|
||||
if (output.creditsUsed == null) {
|
||||
throw new Error('Response missing creditsUsed field')
|
||||
}
|
||||
// $0.001 per credit — from https://example.com/pricing
|
||||
const cost = (output.creditsUsed as number) * 0.001
|
||||
return { cost, metadata: { creditsUsed: output.creditsUsed } }
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**When the API does NOT report cost** — compute it from params/output based on the pricing docs, but still validate the data you depend on:
|
||||
|
||||
```typescript
|
||||
pricing: {
|
||||
type: 'custom',
|
||||
getCost: (params, output) => {
|
||||
if (!Array.isArray(output.searchResults)) {
|
||||
throw new Error('Response missing searchResults, cannot determine cost')
|
||||
}
|
||||
// Serper: 1 credit for <=10 results, 2 credits for >10 — from https://serper.dev/pricing
|
||||
const credits = Number(params.num) > 10 ? 2 : 1
|
||||
return { cost: credits * 0.001, metadata: { credits } }
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**`getCost` must always throw** if it cannot determine cost. Never silently fall back to a default — this would hide billing inaccuracies.
|
||||
|
||||
### Capturing Cost Data from the API
|
||||
|
||||
If the API returns cost info, capture it in `transformResponse` so `getCost` can read it from the output:
|
||||
|
||||
```typescript
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
results: data.results,
|
||||
creditsUsed: data.creditsUsed, // pass through for getCost
|
||||
},
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
For async/polling tools, capture it in `postProcess` when the job completes:
|
||||
|
||||
```typescript
|
||||
if (jobData.status === 'completed') {
|
||||
result.output = {
|
||||
data: jobData.data,
|
||||
creditsUsed: jobData.creditsUsed,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Hide the API Key Field When Hosted
|
||||
|
||||
In the block config (`blocks/blocks/{service}.ts`), add `hideWhenHosted: true` to the API key subblock. This hides the field on hosted Sim since the platform provides the key:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your API key',
|
||||
password: true,
|
||||
required: true,
|
||||
hideWhenHosted: true,
|
||||
},
|
||||
```
|
||||
|
||||
The visibility is controlled by `isSubBlockHidden()` in `lib/workflows/subblocks/visibility.ts`, which checks both the `isHosted` feature flag (`hideWhenHosted`) and optional env var conditions (`hideWhenEnvSet`).
|
||||
|
||||
### Excluding Specific Operations from Hosted Key Support
|
||||
|
||||
When a block has multiple operations but some operations should **not** use a hosted key (e.g., the underlying API is deprecated, unsupported, or too expensive), use the **duplicate apiKey subblock** pattern. This is the same pattern Exa uses for its `research` operation:
|
||||
|
||||
1. **Remove the `hosting` config** from the tool definition for that operation — it must not have a `hosting` object at all.
|
||||
2. **Duplicate the `apiKey` subblock** in the block config with opposing conditions:
|
||||
|
||||
```typescript
|
||||
// API Key — hidden when hosted for operations with hosted key support
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your API key',
|
||||
password: true,
|
||||
required: true,
|
||||
hideWhenHosted: true,
|
||||
condition: { field: 'operation', value: 'unsupported_op', not: true },
|
||||
},
|
||||
// API Key — always visible for unsupported_op (no hosted key support)
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your API key',
|
||||
password: true,
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'unsupported_op' },
|
||||
},
|
||||
```
|
||||
|
||||
Both subblocks share the same `id: 'apiKey'`, so the same value flows to the tool. The conditions ensure only one is visible at a time. The first has `hideWhenHosted: true` and shows for all hosted operations; the second has no `hideWhenHosted` and shows only for the excluded operation — meaning users must always provide their own key for that operation.
|
||||
|
||||
To exclude multiple operations, use an array: `{ field: 'operation', value: ['op_a', 'op_b'] }`.
|
||||
|
||||
**Reference implementations:**
|
||||
- **Exa** (`blocks/blocks/exa.ts`): `research` operation excluded from hosting — lines 309-329
|
||||
- **Google Maps** (`blocks/blocks/google_maps.ts`): `speed_limits` operation excluded from hosting (deprecated Roads API)
|
||||
|
||||
## Step 5: Add to the BYOK Settings UI
|
||||
|
||||
Add an entry to the `PROVIDERS` array in the BYOK settings component so users can bring their own key. You need the service icon from `components/icons.tsx`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'your_service',
|
||||
name: 'Your Service',
|
||||
icon: YourServiceIcon,
|
||||
description: 'What this service does',
|
||||
placeholder: 'Enter your API key',
|
||||
},
|
||||
```
|
||||
|
||||
## Step 6: Summarize Pricing and Throttling Comparison
|
||||
|
||||
After all code changes are complete, output a detailed summary to the user covering:
|
||||
|
||||
### What to include
|
||||
|
||||
1. **API's pricing model** — how the service charges (per token, per credit, per request, etc.), the specific rates found in docs, and whether the API reports cost in responses.
|
||||
2. **Our `getCost` approach** — how we calculate cost, what fields we depend on, and any assumptions or estimates (especially when the API doesn't report exact dollar cost).
|
||||
3. **API's rate limits** — the documented limits (RPM, TPM, concurrent, etc.), which plan tier they apply to, and whether they're per-key or per-account.
|
||||
4. **Our `rateLimit` config** — what we set for `requestsPerMinute` (and dimensions if custom mode), why we chose those values, and how they compare to the API's limits.
|
||||
5. **Key pooling impact** — how many hosted keys we expect, and how round-robin distribution affects the effective per-key rate at the API.
|
||||
6. **Gaps or risks** — anything the API charges for that we don't meter, rate limit dimensions we chose not to enforce, or pricing that may be inaccurate due to variable model/tier costs.
|
||||
|
||||
### Format
|
||||
|
||||
Present this as a structured summary with clear headings. Example:
|
||||
|
||||
```
|
||||
### Pricing
|
||||
- **API charges**: $X per 1M tokens (input), $Y per 1M tokens (output) — varies by model
|
||||
- **Response reports cost?**: No — only token counts in `usage` field
|
||||
- **Our getCost**: Estimates cost at $Z per 1M total tokens based on median model pricing
|
||||
- **Risk**: Actual cost varies by model; our estimate may over/undercharge for cheap/expensive models
|
||||
|
||||
### Throttling
|
||||
- **API limits**: 300 RPM per key (paid tier), 60 RPM (free tier)
|
||||
- **Per-key or per-account**: Per key — more keys = more throughput
|
||||
- **Our config**: 60 RPM per workspace (per_request mode)
|
||||
- **With N keys**: Effective per-key rate is (total RPM across workspaces) / N
|
||||
- **Headroom**: Comfortable — even 10 active workspaces at full rate = 600 RPM / 3 keys = 200 RPM per key, under the 300 RPM API limit
|
||||
```
|
||||
|
||||
This summary helps reviewers verify that the pricing and rate limiting are well-calibrated and surfaces any risks that need monitoring.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Provider added to `BYOKProviderId` in `tools/types.ts`
|
||||
- [ ] Provider added to `VALID_PROVIDERS` in the BYOK keys API route
|
||||
- [ ] API pricing docs researched — understand per-unit cost and whether the API reports cost in responses
|
||||
- [ ] API rate limits researched — understand RPM/TPM limits, per-key vs per-account, and plan tiers
|
||||
- [ ] `hosting` config added to the tool with `envKeyPrefix`, `apiKeyParam`, `byokProviderId`, `pricing`, and `rateLimit`
|
||||
- [ ] `getCost` throws if required cost data is missing from the response
|
||||
- [ ] Cost data captured in `transformResponse` or `postProcess` if API provides it
|
||||
- [ ] `hideWhenHosted: true` added to the API key subblock in the block config
|
||||
- [ ] Provider entry added to the BYOK settings UI with icon and description
|
||||
- [ ] Env vars documented: `{PREFIX}_COUNT` and `{PREFIX}_1..N`
|
||||
- [ ] Pricing and throttling summary provided to reviewer
|
||||
@@ -3,63 +3,57 @@ description: Create webhook triggers for a Sim integration using the generic tri
|
||||
argument-hint: <service-name>
|
||||
---
|
||||
|
||||
# Add Trigger Skill
|
||||
# Add Trigger
|
||||
|
||||
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
|
||||
3. Create a provider handler if custom auth, formatting, or subscriptions are needed
|
||||
4. 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)
|
||||
├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs)
|
||||
├── {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")
|
||||
|
||||
apps/sim/lib/webhooks/
|
||||
├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl)
|
||||
├── providers/
|
||||
│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions)
|
||||
│ ├── types.ts # WebhookProviderHandler interface
|
||||
│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes)
|
||||
│ └── registry.ts # Handler map + default handler
|
||||
```
|
||||
|
||||
## Step 1: Create utils.ts
|
||||
## Step 1: Create `utils.ts`
|
||||
|
||||
This file contains service-specific helpers used by all triggers.
|
||||
This file contains all service-specific helpers used by 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',
|
||||
'Paste the webhook URL and save',
|
||||
'Click "Save" above to activate your trigger',
|
||||
]
|
||||
|
||||
return instructions
|
||||
.map((instruction, index) =>
|
||||
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
|
||||
@@ -67,10 +61,6 @@ export function {service}SetupInstructions(eventType: string): string {
|
||||
.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 [
|
||||
{
|
||||
@@ -78,53 +68,34 @@ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
|
||||
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' },
|
||||
eventType: { type: 'string', description: 'The type of event' },
|
||||
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
|
||||
## Step 2: Create Trigger Files
|
||||
|
||||
The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types.
|
||||
**Primary trigger** — MUST include `includeDropdown: true`:
|
||||
|
||||
```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 { 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',
|
||||
@@ -132,496 +103,222 @@ export const {service}EventATrigger: TriggerConfig = {
|
||||
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
|
||||
includeDropdown: true,
|
||||
setupInstructions: {service}SetupInstructions('Event A'),
|
||||
extraFields: build{Service}ExtraFields('{service}_event_a'),
|
||||
}),
|
||||
|
||||
outputs: build{Service}Outputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
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).
|
||||
**Secondary triggers** — NO `includeDropdown` (it's already in the primary):
|
||||
|
||||
```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',
|
||||
},
|
||||
},
|
||||
// Same as above but: id: '{service}_event_b', no includeDropdown
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Create index.ts Barrel Export
|
||||
## Step 3: Register and Wire
|
||||
|
||||
### `apps/sim/triggers/{service}/index.ts`
|
||||
|
||||
```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`)
|
||||
### `apps/sim/triggers/registry.ts`
|
||||
|
||||
```typescript
|
||||
// Add import
|
||||
import {
|
||||
{service}EventATrigger,
|
||||
{service}EventBTrigger,
|
||||
{service}EventCTrigger,
|
||||
{service}WebhookTrigger,
|
||||
} from '@/triggers/{service}'
|
||||
import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}'
|
||||
|
||||
// Add to TRIGGER_REGISTRY
|
||||
export const TRIGGER_REGISTRY: TriggerRegistry = {
|
||||
// ... existing triggers ...
|
||||
// ... existing ...
|
||||
{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`):
|
||||
### 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',
|
||||
],
|
||||
available: ['{service}_event_a', '{service}_event_b'],
|
||||
},
|
||||
|
||||
subBlocks: [
|
||||
// Regular tool subBlocks first
|
||||
{ id: 'operation', /* ... */ },
|
||||
{ id: 'credential', /* ... */ },
|
||||
// ... other tool fields ...
|
||||
|
||||
// Then spread ALL trigger subBlocks
|
||||
// Regular tool subBlocks first...
|
||||
...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)
|
||||
## Provider Handler
|
||||
|
||||
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.
|
||||
All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`.
|
||||
|
||||
### When to Use Automatic Registration
|
||||
### When to Create a Handler
|
||||
|
||||
Check the service's API documentation for endpoints like:
|
||||
- `POST /webhooks` or `POST /hooks` - Create webhook
|
||||
- `DELETE /webhooks/{id}` - Delete webhook
|
||||
| Behavior | Method | Examples |
|
||||
|---|---|---|
|
||||
| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform |
|
||||
| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms |
|
||||
| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot |
|
||||
| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira |
|
||||
| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby |
|
||||
| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable |
|
||||
| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable |
|
||||
| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams |
|
||||
| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams |
|
||||
|
||||
Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc.
|
||||
If none apply, you don't need a handler. The default handler provides bearer token auth.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
#### 1. Add API Key to Extra Fields
|
||||
|
||||
Update your `build{Service}ExtraFields` function to include an API key field:
|
||||
### Example Handler
|
||||
|
||||
```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 },
|
||||
},
|
||||
]
|
||||
import crypto from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import type { EventMatchContext, FormatInputContext, FormatInputResult, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:{Service}')
|
||||
|
||||
function validate{Service}Signature(secret: string, signature: string, body: string): boolean {
|
||||
if (!secret || !signature || !body) return false
|
||||
const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
|
||||
return safeCompare(computed, signature)
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Update Setup Instructions for Automatic Creation
|
||||
export const {service}Handler: WebhookProviderHandler = {
|
||||
verifyAuth: createHmacVerifier({
|
||||
configKey: 'webhookSecret',
|
||||
headerName: 'X-{Service}-Signature',
|
||||
validateFn: validate{Service}Signature,
|
||||
providerLabel: '{Service}',
|
||||
}),
|
||||
|
||||
Change instructions to indicate automatic webhook creation:
|
||||
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
|
||||
const triggerId = providerConfig.triggerId as string | undefined
|
||||
if (triggerId && triggerId !== '{service}_webhook') {
|
||||
const { is{Service}EventMatch } = await import('@/triggers/{service}/utils')
|
||||
if (!is{Service}EventMatch(triggerId, body as Record<string, unknown>)) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
```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,
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
return {
|
||||
input: {
|
||||
eventType: b.type,
|
||||
resourceId: (b.data as Record<string, unknown>)?.id || '',
|
||||
resource: b.data,
|
||||
},
|
||||
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 }
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
extractIdempotencyId(body: unknown) {
|
||||
const obj = body as Record<string, unknown>
|
||||
return obj.id && obj.type ? `${obj.type}:${obj.id}` : null
|
||||
},
|
||||
}
|
||||
// --- End {Service} specific logic ---
|
||||
```
|
||||
|
||||
Then add the helper function at the end of the file:
|
||||
### Register the Handler
|
||||
|
||||
In `apps/sim/lib/webhooks/providers/registry.ts`:
|
||||
|
||||
```typescript
|
||||
async function create{Service}WebhookSubscription(
|
||||
webhookData: any,
|
||||
requestId: string
|
||||
): Promise<{ id: string } | undefined> {
|
||||
try {
|
||||
const { path, providerConfig } = webhookData
|
||||
const { apiKey, triggerId, projectId } = providerConfig || {}
|
||||
import { {service}Handler } from '@/lib/webhooks/providers/{service}'
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('{Service} API Key is required.')
|
||||
}
|
||||
const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
|
||||
// ... existing (alphabetical) ...
|
||||
{service}: {service}Handler,
|
||||
}
|
||||
```
|
||||
|
||||
// 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
|
||||
}
|
||||
## Output Alignment (Critical)
|
||||
|
||||
const eventType = eventTypeMap[triggerId]
|
||||
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
|
||||
There are two sources of truth that **MUST be aligned**:
|
||||
|
||||
const requestBody: Record<string, any> = {
|
||||
url: notificationUrl,
|
||||
}
|
||||
1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown)
|
||||
2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data
|
||||
|
||||
if (eventType) {
|
||||
requestBody.eventType = eventType
|
||||
}
|
||||
If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover.
|
||||
|
||||
if (projectId) {
|
||||
requestBody.projectId = projectId
|
||||
}
|
||||
**Rules for `formatInput`:**
|
||||
- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly
|
||||
- Return `{ input: ..., skip: { message: '...' } }` to skip execution
|
||||
- No wrapper objects or duplication
|
||||
- Use `null` for missing optional data
|
||||
|
||||
const response = await fetch('https://api.{service}.com/webhooks', {
|
||||
## Automatic Webhook Registration
|
||||
|
||||
If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**.
|
||||
|
||||
```typescript
|
||||
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
|
||||
import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types'
|
||||
|
||||
export const {service}Handler: WebhookProviderHandler = {
|
||||
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
|
||||
const config = getProviderConfig(ctx.webhook)
|
||||
const apiKey = config.apiKey as string
|
||||
if (!apiKey) throw new Error('{Service} API Key is required.')
|
||||
|
||||
const res = await fetch('https://api.{service}.com/webhooks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }),
|
||||
})
|
||||
|
||||
const responseBody = await response.json()
|
||||
if (!res.ok) throw new Error(`{Service} error: ${res.status}`)
|
||||
const { id } = (await res.json()) as { id: string }
|
||||
return { providerConfigUpdates: { externalId: id } }
|
||||
},
|
||||
|
||||
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}`, {
|
||||
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
|
||||
const config = getProviderConfig(ctx.webhook)
|
||||
const { apiKey, externalId } = config as { apiKey?: string; externalId?: string }
|
||||
if (!apiKey || !externalId) return
|
||||
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)
|
||||
}
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}).catch(() => {})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
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:**
|
||||
- Throw from `createSubscription` — orchestration rolls back the DB webhook
|
||||
- Never throw from `deleteSubscription` — log non-fatally
|
||||
- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig`
|
||||
- Add `apiKey` field to `build{Service}ExtraFields` with `password: true`
|
||||
|
||||
### 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 Schema
|
||||
|
||||
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)
|
||||
**Supported:** `type` + `description` for leaf fields, nested objects for complex data.
|
||||
**NOT supported:** `optional: true`, `items` (those are tool-output-only features).
|
||||
|
||||
```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' },
|
||||
@@ -630,79 +327,32 @@ export function buildOutputs(): Record<string, TriggerOutput> {
|
||||
}
|
||||
```
|
||||
|
||||
## Generic Webhook Trigger Pattern
|
||||
## Checklist
|
||||
|
||||
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`
|
||||
### Trigger Definition
|
||||
- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders
|
||||
- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT
|
||||
- [ ] 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`
|
||||
- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY`
|
||||
- [ ] Block has `triggers.enabled: true` and lists 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
|
||||
### Provider Handler (if needed)
|
||||
- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts`
|
||||
- [ ] Registered in `providers/registry.ts` (alphabetical)
|
||||
- [ ] Signature validator is a private function inside the handler file
|
||||
- [ ] `formatInput` output keys match trigger `outputs` exactly
|
||||
- [ ] Event matching uses dynamic `await import()` for trigger utils
|
||||
|
||||
### 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
|
||||
### Auto Registration (if supported)
|
||||
- [ ] `createSubscription` and `deleteSubscription` on the handler
|
||||
- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
|
||||
- [ ] API key field uses `password: true`
|
||||
|
||||
### 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)
|
||||
- [ ] `bun run type-check` passes
|
||||
- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys
|
||||
- [ ] Trigger UI shows correctly in the block
|
||||
|
||||
12
.claude/commands/council.md
Normal file
12
.claude/commands/council.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: Spawn task agents to explore a given area of interest in the codebase
|
||||
argument-hint: <area-of-interest>
|
||||
---
|
||||
|
||||
Based on the given area of interest, please:
|
||||
|
||||
1. Dig around the codebase in terms of that given area of interest, gather general information such as keywords and architecture overview.
|
||||
2. Spawn off n=10 (unless specified otherwise) task agents to dig deeper into the codebase in terms of that given area of interest, some of them should be out of the box for variance.
|
||||
3. Once the task agents are done, use the information to do what the user wants.
|
||||
|
||||
If user is in plan mode, use the information to create the plan.
|
||||
212
.claude/commands/validate-trigger.md
Normal file
212
.claude/commands/validate-trigger.md
Normal file
@@ -0,0 +1,212 @@
|
||||
---
|
||||
description: Validate an existing Sim webhook trigger against provider API docs and repository conventions
|
||||
argument-hint: <service-name> [api-docs-url]
|
||||
---
|
||||
|
||||
# Validate Trigger
|
||||
|
||||
You are an expert auditor for Sim webhook triggers. Your job is to validate that an existing trigger implementation is correct, complete, secure, and aligned across all layers.
|
||||
|
||||
## Your Task
|
||||
|
||||
1. Read the service's webhook/API documentation (via WebFetch)
|
||||
2. Read every trigger file, provider handler, and registry entry
|
||||
3. Cross-reference against the API docs and Sim conventions
|
||||
4. Report all issues grouped by severity (critical, warning, suggestion)
|
||||
5. Fix all issues after reporting them
|
||||
|
||||
## Step 1: Gather All Files
|
||||
|
||||
Read **every** file for the trigger — do not skip any:
|
||||
|
||||
```
|
||||
apps/sim/triggers/{service}/ # All trigger files, utils.ts, index.ts
|
||||
apps/sim/lib/webhooks/providers/{service}.ts # Provider handler (if exists)
|
||||
apps/sim/lib/webhooks/providers/registry.ts # Handler registry
|
||||
apps/sim/triggers/registry.ts # Trigger registry
|
||||
apps/sim/blocks/blocks/{service}.ts # Block definition (trigger wiring)
|
||||
```
|
||||
|
||||
Also read for reference:
|
||||
```
|
||||
apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface
|
||||
apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.)
|
||||
apps/sim/lib/webhooks/provider-subscription-utils.ts # Subscription helpers
|
||||
apps/sim/lib/webhooks/processor.ts # Central webhook processor
|
||||
```
|
||||
|
||||
## Step 2: Pull API Documentation
|
||||
|
||||
Fetch the service's official webhook documentation. This is the **source of truth** for:
|
||||
- Webhook event types and payload shapes
|
||||
- Signature/auth verification method (HMAC algorithm, header names, secret format)
|
||||
- Challenge/verification handshake requirements
|
||||
- Webhook subscription API (create/delete endpoints, if applicable)
|
||||
- Retry behavior and delivery guarantees
|
||||
|
||||
## Step 3: Validate Trigger Definitions
|
||||
|
||||
### utils.ts
|
||||
- [ ] `{service}TriggerOptions` lists all trigger IDs accurately
|
||||
- [ ] `{service}SetupInstructions` provides clear, correct steps for the service
|
||||
- [ ] `build{Service}ExtraFields` includes relevant filter/config fields with correct `condition`
|
||||
- [ ] Output builders expose all meaningful fields from the webhook payload
|
||||
- [ ] Output builders do NOT use `optional: true` or `items` (tool-output-only features)
|
||||
- [ ] Nested output objects correctly model the payload structure
|
||||
|
||||
### Trigger Files
|
||||
- [ ] Exactly one primary trigger has `includeDropdown: true`
|
||||
- [ ] All secondary triggers do NOT have `includeDropdown`
|
||||
- [ ] All triggers use `buildTriggerSubBlocks` helper (not hand-rolled subBlocks)
|
||||
- [ ] Every trigger's `id` matches the convention `{service}_{event_name}`
|
||||
- [ ] Every trigger's `provider` matches the service name used in the handler registry
|
||||
- [ ] `index.ts` barrel exports all triggers
|
||||
|
||||
### Trigger ↔ Provider Alignment (CRITICAL)
|
||||
- [ ] Every trigger ID referenced in `matchEvent` logic exists in `{service}TriggerOptions`
|
||||
- [ ] Event matching logic in the provider correctly maps trigger IDs to service event types
|
||||
- [ ] Event matching logic in `is{Service}EventMatch` (if exists) correctly identifies events per the API docs
|
||||
|
||||
## Step 4: Validate Provider Handler
|
||||
|
||||
### Auth Verification
|
||||
- [ ] `verifyAuth` correctly validates webhook signatures per the service's documentation
|
||||
- [ ] HMAC algorithm matches (SHA-1, SHA-256, SHA-512)
|
||||
- [ ] Signature header name matches the API docs exactly
|
||||
- [ ] Signature format is handled (raw hex, `sha256=` prefix, base64, etc.)
|
||||
- [ ] Uses `safeCompare` for timing-safe comparison (no `===`)
|
||||
- [ ] If `webhookSecret` is required, handler rejects when it's missing (fail-closed)
|
||||
- [ ] Signature is computed over raw body (not parsed JSON)
|
||||
|
||||
### Event Matching
|
||||
- [ ] `matchEvent` returns `boolean` (not `NextResponse` or other values)
|
||||
- [ ] Challenge/verification events are excluded from matching (e.g., `endpoint.url_validation`)
|
||||
- [ ] When `triggerId` is a generic webhook ID, all events pass through
|
||||
- [ ] When `triggerId` is specific, only matching events pass
|
||||
- [ ] Event matching logic uses dynamic `await import()` for trigger utils (avoids circular deps)
|
||||
|
||||
### formatInput (CRITICAL)
|
||||
- [ ] Every key in the `formatInput` return matches a key in the trigger `outputs` schema
|
||||
- [ ] Every key in the trigger `outputs` schema is populated by `formatInput`
|
||||
- [ ] No extra undeclared keys that users can't discover in the UI
|
||||
- [ ] No wrapper objects (`webhook: { ... }`, `{service}: { ... }`)
|
||||
- [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`)
|
||||
- [ ] `null` is used for missing optional fields (not empty strings or empty objects)
|
||||
- [ ] Returns `{ input: { ... } }` — not a bare object
|
||||
|
||||
### Idempotency
|
||||
- [ ] `extractIdempotencyId` returns a stable, unique key per delivery
|
||||
- [ ] Uses provider-specific delivery IDs when available (e.g., `X-Request-Id`, `Linear-Delivery`, `svix-id`)
|
||||
- [ ] Falls back to content-based ID (e.g., `${type}:${id}`) when no delivery header exists
|
||||
- [ ] Does NOT include timestamps in the idempotency key (would break dedup on retries)
|
||||
|
||||
### Challenge Handling (if applicable)
|
||||
- [ ] `handleChallenge` correctly implements the service's URL verification handshake
|
||||
- [ ] Returns the expected response format per the API docs
|
||||
- [ ] Env-backed secrets are resolved via `resolveEnvVarsInObject` if needed
|
||||
|
||||
## Step 5: Validate Automatic Subscription Lifecycle
|
||||
|
||||
If the service supports programmatic webhook creation:
|
||||
|
||||
### createSubscription
|
||||
- [ ] Calls the correct API endpoint to create a webhook
|
||||
- [ ] Sends the correct event types/filters
|
||||
- [ ] Passes the notification URL from `getNotificationUrl(ctx.webhook)`
|
||||
- [ ] Returns `{ providerConfigUpdates: { externalId } }` with the external webhook ID
|
||||
- [ ] Throws on failure (orchestration handles rollback)
|
||||
- [ ] Provides user-friendly error messages (401 → "Invalid API Key", etc.)
|
||||
|
||||
### deleteSubscription
|
||||
- [ ] Calls the correct API endpoint to delete the webhook
|
||||
- [ ] Handles 404 gracefully (webhook already deleted)
|
||||
- [ ] Never throws — catches errors and logs non-fatally
|
||||
- [ ] Skips gracefully when `apiKey` or `externalId` is missing
|
||||
|
||||
### Orchestration Isolation
|
||||
- [ ] NO provider-specific logic in `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
|
||||
- [ ] All subscription logic lives on the handler (`createSubscription`/`deleteSubscription`)
|
||||
|
||||
## Step 6: Validate Registration and Block Wiring
|
||||
|
||||
### Trigger Registry (`triggers/registry.ts`)
|
||||
- [ ] All triggers are imported and registered
|
||||
- [ ] Registry keys match trigger IDs exactly
|
||||
- [ ] No orphaned entries (triggers that don't exist)
|
||||
|
||||
### Provider Handler Registry (`providers/registry.ts`)
|
||||
- [ ] Handler is imported and registered (if handler exists)
|
||||
- [ ] Registry key matches the `provider` field on the trigger configs
|
||||
- [ ] Entries are in alphabetical order
|
||||
|
||||
### Block Wiring (`blocks/blocks/{service}.ts`)
|
||||
- [ ] Block has `triggers.enabled: true`
|
||||
- [ ] `triggers.available` lists all trigger IDs
|
||||
- [ ] All trigger subBlocks are spread into `subBlocks`: `...getTrigger('id').subBlocks`
|
||||
- [ ] No trigger IDs in `triggers.available` that aren't in the registry
|
||||
- [ ] No trigger subBlocks spread that aren't in `triggers.available`
|
||||
|
||||
## Step 7: Validate Security
|
||||
|
||||
- [ ] Webhook secrets are never logged (not even at debug level)
|
||||
- [ ] Auth verification runs before any event processing
|
||||
- [ ] No secret comparison uses `===` (must use `safeCompare` or `crypto.timingSafeEqual`)
|
||||
- [ ] Timestamp/replay protection is reasonable (not too tight for retries, not too loose for security)
|
||||
- [ ] Raw body is used for signature verification (not re-serialized JSON)
|
||||
|
||||
## Step 8: Report and Fix
|
||||
|
||||
### Report Format
|
||||
|
||||
Group findings by severity:
|
||||
|
||||
**Critical** (runtime errors, security issues, or data loss):
|
||||
- Wrong HMAC algorithm or header name
|
||||
- `formatInput` keys don't match trigger `outputs`
|
||||
- Missing `verifyAuth` when the service sends signed webhooks
|
||||
- `matchEvent` returns non-boolean values
|
||||
- Provider-specific logic leaking into shared orchestration files
|
||||
- Trigger IDs mismatch between trigger files, registry, and block
|
||||
- `createSubscription` calling wrong API endpoint
|
||||
- Auth comparison using `===` instead of `safeCompare`
|
||||
|
||||
**Warning** (convention violations or usability issues):
|
||||
- Missing `extractIdempotencyId` when the service provides delivery IDs
|
||||
- Timestamps in idempotency keys (breaks dedup on retries)
|
||||
- Missing challenge handling when the service requires URL verification
|
||||
- Output schema missing fields that `formatInput` returns (undiscoverable data)
|
||||
- Overly tight timestamp skew window that rejects legitimate retries
|
||||
- `matchEvent` not filtering challenge/verification events
|
||||
- Setup instructions missing important steps
|
||||
|
||||
**Suggestion** (minor improvements):
|
||||
- More specific output field descriptions
|
||||
- Additional output fields that could be exposed
|
||||
- Better error messages in `createSubscription`
|
||||
- Logging improvements
|
||||
|
||||
### 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 type-check` passes
|
||||
2. Re-read all modified files to verify fixes are correct
|
||||
3. Provider handler tests pass (if they exist): `bun test {service}`
|
||||
|
||||
## Checklist Summary
|
||||
|
||||
- [ ] Read all trigger files, provider handler, types, registries, and block
|
||||
- [ ] Pulled and read official webhook/API documentation
|
||||
- [ ] Validated trigger definitions: options, instructions, extra fields, outputs
|
||||
- [ ] Validated primary/secondary trigger distinction (`includeDropdown`)
|
||||
- [ ] Validated provider handler: auth, matchEvent, formatInput, idempotency
|
||||
- [ ] Validated output alignment: every `outputs` key ↔ every `formatInput` key
|
||||
- [ ] Validated subscription lifecycle: createSubscription, deleteSubscription, no shared-file edits
|
||||
- [ ] Validated registration: trigger registry, handler registry, block wiring
|
||||
- [ ] Validated security: safe comparison, no secret logging, replay protection
|
||||
- [ ] Reported all issues grouped by severity
|
||||
- [ ] Fixed all critical and warning issues
|
||||
- [ ] `bun run type-check` passes after fixes
|
||||
17
.claude/commands/you-might-not-need-an-effect.md
Normal file
17
.claude/commands/you-might-not-need-an-effect.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
description: Analyze and fix useEffect anti-patterns in your code
|
||||
argument-hint: [scope] [fix=true|false]
|
||||
---
|
||||
|
||||
# You Might Not Need an Effect
|
||||
|
||||
Arguments:
|
||||
- scope: what to analyze (default: your current changes). Examples: "diff to main", "PR #123", "src/components/", "whole codebase"
|
||||
- fix: whether to apply fixes (default: true). Set to false to only propose changes.
|
||||
|
||||
User arguments: $ARGUMENTS
|
||||
|
||||
Steps:
|
||||
1. Read https://react.dev/learn/you-might-not-need-an-effect to understand the guidelines
|
||||
2. Analyze the specified scope for useEffect anti-patterns
|
||||
3. If fix=true, apply the fixes. If fix=false, propose the fixes without applying.
|
||||
@@ -9,5 +9,26 @@ Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||
## Styling
|
||||
Never update global styles. Keep all styling local to components.
|
||||
|
||||
## ID Generation
|
||||
Never use `crypto.randomUUID()`, `nanoid`, or the `uuid` package directly. Use the utilities from `@/lib/core/utils/uuid`:
|
||||
|
||||
- `generateId()` — UUID v4, use by default
|
||||
- `generateShortId(size?)` — short URL-safe ID (default 21 chars), for compact identifiers
|
||||
|
||||
Both use `crypto.getRandomValues()` under the hood and work in all contexts including non-secure (HTTP) browsers.
|
||||
|
||||
```typescript
|
||||
// ✗ Bad
|
||||
import { nanoid } from 'nanoid'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// ✓ Good
|
||||
import { generateId, generateShortId } from '@/lib/core/utils/uuid'
|
||||
const uuid = generateId()
|
||||
const shortId = generateShortId()
|
||||
const tiny = generateShortId(8)
|
||||
```
|
||||
|
||||
## Package Manager
|
||||
Use `bun` and `bunx`, not `npm` and `npx`.
|
||||
|
||||
26
.claude/rules/landing-seo-geo.md
Normal file
26
.claude/rules/landing-seo-geo.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/app/(home)/**/*.tsx"
|
||||
---
|
||||
|
||||
# Landing Page — SEO / GEO
|
||||
|
||||
## SEO
|
||||
|
||||
- One `<h1>` per page, in Hero only — never add another.
|
||||
- Strict heading hierarchy: H1 (Hero) → H2 (section titles) → H3 (feature names).
|
||||
- Every section: `<section id="…" aria-labelledby="…-heading">`.
|
||||
- Decorative/animated elements: `aria-hidden="true"`.
|
||||
- All internal routes use Next.js `<Link>` (crawlable). External links get `rel="noopener noreferrer"`.
|
||||
- Navbar is a Server Component (no `'use client'`) for immediate crawlability. Logo `<Image>` has `priority` (LCP element).
|
||||
- Navbar `<nav>` carries `SiteNavigationElement` schema.org markup.
|
||||
- Feature lists must stay in sync with `WebApplication.featureList` in `structured-data.tsx`.
|
||||
|
||||
## GEO (Generative Engine Optimisation)
|
||||
|
||||
- **Answer-first pattern**: each section's H2 + subtitle should directly answer a user question (e.g. "What is Sim?", "How fast can I deploy?").
|
||||
- **Atomic answer blocks**: each feature / template card should be independently extractable by an AI summariser.
|
||||
- **Entity consistency**: always write "Sim" by name — never "the platform" or "our tool".
|
||||
- **Keyword density**: first 150 visible chars of Hero must name "Sim", "AI agents", "agentic workflows".
|
||||
- **sr-only summaries**: Hero and Templates each have a `<p className="sr-only">` (~50 words) as an atomic product/catalog summary for AI citation.
|
||||
- **Specific numbers**: prefer concrete figures ("1,000+ integrations", "15+ AI providers") over vague claims.
|
||||
@@ -5,62 +5,122 @@ paths:
|
||||
|
||||
# React Query Patterns
|
||||
|
||||
All React Query hooks live in `hooks/queries/`.
|
||||
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 query file defines a keys factory:
|
||||
Every query file defines a hierarchical keys factory with an `all` root key and intermediate plural keys for prefix-level invalidation:
|
||||
|
||||
```typescript
|
||||
export const entityKeys = {
|
||||
all: ['entity'] as const,
|
||||
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
|
||||
detail: (id?: string) => [...entityKeys.all, 'detail', id ?? ''] 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,
|
||||
}
|
||||
```
|
||||
|
||||
Never use inline query keys — always use the factory.
|
||||
|
||||
## File Structure
|
||||
|
||||
```typescript
|
||||
// 1. Query keys factory
|
||||
// 2. Types (if needed)
|
||||
// 3. Private fetch functions
|
||||
// 3. Private fetch functions (accept signal parameter)
|
||||
// 4. Exported hooks
|
||||
```
|
||||
|
||||
## Query Hook
|
||||
|
||||
- Every `queryFn` must destructure and 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
|
||||
async function fetchEntities(workspaceId: string, signal?: AbortSignal) {
|
||||
const response = await fetch(`/api/entities?workspaceId=${workspaceId}`, { signal })
|
||||
if (!response.ok) throw new Error('Failed to fetch entities')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: entityKeys.list(workspaceId),
|
||||
queryFn: () => fetchEntities(workspaceId as string),
|
||||
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
placeholderData: keepPreviousData, // OK: workspaceId varies
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation Hook
|
||||
|
||||
- Use targeted invalidation (`entityKeys.lists()`) not broad (`entityKeys.all`) when possible
|
||||
- Invalidation must cover all affected query key prefixes (lists, details, related views)
|
||||
|
||||
```typescript
|
||||
export function useCreateEntity() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables) => { /* fetch POST */ },
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: entityKeys.all }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Optimistic Updates
|
||||
|
||||
For optimistic mutations, use `onSettled` (not `onSuccess`) for cache reconciliation — `onSettled` fires on both success and error, ensuring the cache is always reconciled with the server.
|
||||
|
||||
```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 value */)
|
||||
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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
For optimistic mutations syncing with Zustand, use `createOptimisticMutationHandlers` from `@/hooks/queries/utils/optimistic-mutation`.
|
||||
|
||||
## useCallback Dependencies
|
||||
|
||||
Never include mutation objects (e.g., `createEntity`) in `useCallback` dependency arrays — the mutation object is not referentially stable and changes on every state update. The `.mutate()` and `.mutateAsync()` functions are stable in TanStack Query v5.
|
||||
|
||||
```typescript
|
||||
// ✗ Bad — causes unnecessary recreations
|
||||
const handler = useCallback(() => {
|
||||
createEntity.mutate(data)
|
||||
}, [createEntity]) // unstable reference
|
||||
|
||||
// ✓ Good — omit from deps, mutate is stable
|
||||
const handler = useCallback(() => {
|
||||
createEntity.mutate(data)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
```
|
||||
|
||||
## Naming
|
||||
|
||||
- **Keys**: `entityKeys`
|
||||
- **Query hooks**: `useEntity`, `useEntityList`
|
||||
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`
|
||||
- **Fetch functions**: `fetchEntity` (private)
|
||||
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`, `useDeleteEntity`
|
||||
- **Fetch functions**: `fetchEntity`, `fetchEntities` (private)
|
||||
|
||||
826
.cursor/commands/add-block.md
Normal file
826
.cursor/commands/add-block.md
Normal file
@@ -0,0 +1,826 @@
|
||||
# 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, IntegrationType } 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'
|
||||
integrationType: IntegrationType.X, // Primary category (see IntegrationType enum)
|
||||
tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type)
|
||||
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, IntegrationType } 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',
|
||||
integrationType: IntegrationType.DeveloperTools,
|
||||
tags: ['oauth', 'api'],
|
||||
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
|
||||
|
||||
- [ ] `integrationType` is set to the correct `IntegrationType` enum value
|
||||
- [ ] `tags` array includes all applicable `IntegrationTag` values
|
||||
- [ ] 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
|
||||
523
.cursor/commands/add-connector.md
Normal file
523
.cursor/commands/add-connector.md
Normal file
@@ -0,0 +1,523 @@
|
||||
# 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) => {
|
||||
// Return metadata stubs with contentDeferred: true (if per-doc content fetch needed)
|
||||
// Or full documents with content (if list API returns content inline)
|
||||
// Return { documents: ExternalDocument[], nextCursor?, hasMore }
|
||||
},
|
||||
|
||||
getDocument: async (accessToken, sourceConfig, externalId) => {
|
||||
// Fetch full content for a single document
|
||||
// Return ExternalDocument with contentDeferred: false, 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 (or '' if contentDeferred)
|
||||
contentDeferred?: boolean // true = content will be fetched via getDocument
|
||||
mimeType: 'text/plain' // Always text/plain (content is extracted)
|
||||
contentHash: string // Metadata-based hash for change detection
|
||||
sourceUrl?: string // Link back to original (stored on document record)
|
||||
metadata?: Record<string, unknown> // Source-specific data (fed to mapTags)
|
||||
}
|
||||
```
|
||||
|
||||
## Content Deferral (Required for file/content-download connectors)
|
||||
|
||||
**All connectors that require per-document API calls to fetch content MUST use `contentDeferred: true`.** This is the standard pattern — `listDocuments` returns lightweight metadata stubs, and content is fetched lazily by the sync engine via `getDocument` only for new/changed documents.
|
||||
|
||||
This pattern is critical for reliability: the sync engine processes documents in batches and enqueues each batch for processing immediately. If a sync times out, all previously-batched documents are already queued. Without deferral, content downloads during listing can exhaust the sync task's time budget before any documents are saved.
|
||||
|
||||
### When to use `contentDeferred: true`
|
||||
|
||||
- The service's list API does NOT return document content (only metadata)
|
||||
- Content requires a separate download/export API call per document
|
||||
- Examples: Google Drive, OneDrive, SharePoint, Dropbox, Notion, Confluence, Gmail, Obsidian, Evernote, GitHub
|
||||
|
||||
### When NOT to use `contentDeferred`
|
||||
|
||||
- The list API already returns the full content inline (e.g., Slack messages, Reddit posts, HubSpot notes)
|
||||
- No per-document API call is needed to get content
|
||||
|
||||
### Content Hash Strategy
|
||||
|
||||
Use a **metadata-based** `contentHash` — never a content-based hash. The hash must be derivable from the list response metadata alone, so the sync engine can detect changes without downloading content.
|
||||
|
||||
Good metadata hash sources:
|
||||
- `modifiedTime` / `lastModifiedDateTime` — changes when file is edited
|
||||
- Git blob SHA — unique per content version
|
||||
- API-provided content hash (e.g., Dropbox `content_hash`)
|
||||
- Version number (e.g., Confluence page version)
|
||||
|
||||
Format: `{service}:{id}:{changeIndicator}`
|
||||
|
||||
```typescript
|
||||
// Google Drive: modifiedTime changes on edit
|
||||
contentHash: `gdrive:${file.id}:${file.modifiedTime ?? ''}`
|
||||
|
||||
// GitHub: blob SHA is a content-addressable hash
|
||||
contentHash: `gitsha:${item.sha}`
|
||||
|
||||
// Dropbox: API provides content_hash
|
||||
contentHash: `dropbox:${entry.id}:${entry.content_hash ?? entry.server_modified}`
|
||||
|
||||
// Confluence: version number increments on edit
|
||||
contentHash: `confluence:${page.id}:${page.version.number}`
|
||||
```
|
||||
|
||||
**Critical invariant:** The `contentHash` MUST be identical whether produced by `listDocuments` (stub) or `getDocument` (full doc). Both should use the same stub function to guarantee this.
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
```typescript
|
||||
// 1. Create a stub function (sync, no API calls)
|
||||
function fileToStub(file: ServiceFile): ExternalDocument {
|
||||
return {
|
||||
externalId: file.id,
|
||||
title: file.name || 'Untitled',
|
||||
content: '',
|
||||
contentDeferred: true,
|
||||
mimeType: 'text/plain',
|
||||
sourceUrl: `https://service.com/file/${file.id}`,
|
||||
contentHash: `service:${file.id}:${file.modifiedTime ?? ''}`,
|
||||
metadata: { /* fields needed by mapTags */ },
|
||||
}
|
||||
}
|
||||
|
||||
// 2. listDocuments returns stubs (fast, metadata only)
|
||||
listDocuments: async (accessToken, sourceConfig, cursor) => {
|
||||
const response = await fetchWithRetry(listUrl, { ... })
|
||||
const files = (await response.json()).files
|
||||
const documents = files.map(fileToStub)
|
||||
return { documents, nextCursor, hasMore }
|
||||
}
|
||||
|
||||
// 3. getDocument fetches content and returns full doc with SAME contentHash
|
||||
getDocument: async (accessToken, sourceConfig, externalId) => {
|
||||
const metadata = await fetchWithRetry(metadataUrl, { ... })
|
||||
const file = await metadata.json()
|
||||
if (file.trashed) return null
|
||||
|
||||
try {
|
||||
const content = await fetchContent(accessToken, file)
|
||||
if (!content.trim()) return null
|
||||
const stub = fileToStub(file)
|
||||
return { ...stub, content, contentDeferred: false }
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch content for: ${file.name}`, { error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reference Implementations
|
||||
|
||||
- **Google Drive**: `connectors/google-drive/google-drive.ts` — file download/export with `modifiedTime` hash
|
||||
- **GitHub**: `connectors/github/github.ts` — git blob SHA hash
|
||||
- **Notion**: `connectors/notion/notion.ts` — blocks API with `last_edited_time` hash
|
||||
- **Confluence**: `connectors/confluence/confluence.ts` — version number hash
|
||||
|
||||
## 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 + contentDeferred**: `apps/sim/connectors/google-drive/google-drive.ts` — file download with metadata-based hash, `orderBy` for deterministic pagination
|
||||
- **OAuth + contentDeferred (blocks API)**: `apps/sim/connectors/notion/notion.ts` — complex block content extraction deferred to `getDocument`
|
||||
- **OAuth + contentDeferred (git)**: `apps/sim/connectors/github/github.ts` — blob SHA hash, tree listing
|
||||
- **OAuth + inline content**: `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 with metadata-based content hashes
|
||||
- [ ] `contentDeferred: true` used if content requires per-doc API calls (file download, export, blocks fetch)
|
||||
- [ ] `contentHash` is metadata-based (not content-based) and identical between stub and `getDocument`
|
||||
- [ ] `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`
|
||||
291
.cursor/commands/add-hosted-key.md
Normal file
291
.cursor/commands/add-hosted-key.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Adding Hosted Key Support to a Tool
|
||||
|
||||
When a tool has hosted key support, Sim provides its own API key if the user hasn't configured one (via BYOK or env var). Usage is metered and billed to the workspace.
|
||||
|
||||
## Overview
|
||||
|
||||
| Step | What | Where |
|
||||
|------|------|-------|
|
||||
| 1 | Register BYOK provider ID | `tools/types.ts`, `app/api/workspaces/[id]/byok-keys/route.ts` |
|
||||
| 2 | Research the API's pricing and rate limits | API docs / pricing page (before writing any code) |
|
||||
| 3 | Add `hosting` config to the tool | `tools/{service}/{action}.ts` |
|
||||
| 4 | Hide API key field when hosted | `blocks/blocks/{service}.ts` |
|
||||
| 5 | Add to BYOK settings UI | BYOK settings component (`byok.tsx`) |
|
||||
| 6 | Summarize pricing and throttling comparison | Output to user (after all code changes) |
|
||||
|
||||
## Step 1: Register the BYOK Provider ID
|
||||
|
||||
Add the new provider to the `BYOKProviderId` union in `tools/types.ts`:
|
||||
|
||||
```typescript
|
||||
export type BYOKProviderId =
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
// ...existing providers
|
||||
| 'your_service'
|
||||
```
|
||||
|
||||
Then add it to `VALID_PROVIDERS` in `app/api/workspaces/[id]/byok-keys/route.ts`:
|
||||
|
||||
```typescript
|
||||
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'your_service'] as const
|
||||
```
|
||||
|
||||
## Step 2: Research the API's Pricing Model and Rate Limits
|
||||
|
||||
**Before writing any `getCost` or `rateLimit` code**, look up the service's official documentation for both pricing and rate limits. You need to understand:
|
||||
|
||||
### Pricing
|
||||
|
||||
1. **How the API charges** — per request, per credit, per token, per step, per minute, etc.
|
||||
2. **Whether the API reports cost in its response** — look for fields like `creditsUsed`, `costDollars`, `tokensUsed`, or similar in the response body or headers
|
||||
3. **Whether cost varies by endpoint/options** — some APIs charge more for certain features (e.g., Firecrawl charges 1 credit/page base but +4 for JSON format, +4 for enhanced mode)
|
||||
4. **The dollar-per-unit rate** — what each credit/token/unit costs in dollars on our plan
|
||||
|
||||
### Rate Limits
|
||||
|
||||
1. **What rate limits the API enforces** — requests per minute/second, tokens per minute, concurrent requests, etc.
|
||||
2. **Whether limits vary by plan tier** — free vs paid vs enterprise often have different ceilings
|
||||
3. **Whether limits are per-key or per-account** — determines whether adding more hosted keys actually increases total throughput
|
||||
4. **What the API returns when rate limited** — HTTP 429, `Retry-After` header, error body format, etc.
|
||||
5. **Whether there are multiple dimensions** — some APIs limit both requests/min AND tokens/min independently
|
||||
|
||||
Search the API's docs/pricing page (use WebSearch/WebFetch). Capture the pricing model as a comment in `getCost` so future maintainers know the source of truth.
|
||||
|
||||
### Setting Our Rate Limits
|
||||
|
||||
Our rate limiter (`lib/core/rate-limiter/hosted-key/`) uses a token-bucket algorithm applied **per billing actor** (workspace). It supports two modes:
|
||||
|
||||
- **`per_request`** — simple; just `requestsPerMinute`. Good when the API charges flat per-request or cost doesn't vary much.
|
||||
- **`custom`** — `requestsPerMinute` plus additional `dimensions` (e.g., `tokens`, `search_units`). Each dimension has its own `limitPerMinute` and an `extractUsage` function that reads actual usage from the response. Use when the API charges on a variable metric (tokens, credits) and you want to cap that metric too.
|
||||
|
||||
When choosing values for `requestsPerMinute` and any dimension limits:
|
||||
|
||||
- **Stay well below the API's per-key limit** — our keys are shared across all workspaces. If the API allows 60 RPM per key and we have 3 keys, the global ceiling is ~180 RPM. Set the per-workspace limit low enough (e.g., 20-60 RPM) that many workspaces can coexist without collectively hitting the API's ceiling.
|
||||
- **Account for key pooling** — our round-robin distributes requests across `N` hosted keys, so the effective API-side rate per key is `(total requests) / N`. But per-workspace limits are enforced *before* key selection, so they apply regardless of key count.
|
||||
- **Prefer conservative defaults** — it's easy to raise limits later but hard to claw back after users depend on high throughput.
|
||||
|
||||
## Step 3: Add `hosting` Config to the Tool
|
||||
|
||||
Add a `hosting` object to the tool's `ToolConfig`. This tells the execution layer how to acquire hosted keys, calculate cost, and rate-limit.
|
||||
|
||||
```typescript
|
||||
hosting: {
|
||||
envKeyPrefix: 'YOUR_SERVICE_API_KEY',
|
||||
apiKeyParam: 'apiKey',
|
||||
byokProviderId: 'your_service',
|
||||
pricing: {
|
||||
type: 'custom',
|
||||
getCost: (_params, output) => {
|
||||
if (output.creditsUsed == null) {
|
||||
throw new Error('Response missing creditsUsed field')
|
||||
}
|
||||
const creditsUsed = output.creditsUsed as number
|
||||
const cost = creditsUsed * 0.001 // dollars per credit
|
||||
return { cost, metadata: { creditsUsed } }
|
||||
},
|
||||
},
|
||||
rateLimit: {
|
||||
mode: 'per_request',
|
||||
requestsPerMinute: 100,
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Hosted Key Env Var Convention
|
||||
|
||||
Keys use a numbered naming pattern driven by a count env var:
|
||||
|
||||
```
|
||||
YOUR_SERVICE_API_KEY_COUNT=3
|
||||
YOUR_SERVICE_API_KEY_1=sk-...
|
||||
YOUR_SERVICE_API_KEY_2=sk-...
|
||||
YOUR_SERVICE_API_KEY_3=sk-...
|
||||
```
|
||||
|
||||
The `envKeyPrefix` value (`YOUR_SERVICE_API_KEY`) determines which env vars are read at runtime. Adding more keys only requires bumping the count and adding the new env var.
|
||||
|
||||
### Pricing: Prefer API-Reported Cost
|
||||
|
||||
Always prefer using cost data returned by the API (e.g., `creditsUsed`, `costDollars`). This is the most accurate because it accounts for variable pricing tiers, feature modifiers, and plan-level discounts.
|
||||
|
||||
**When the API reports cost** — use it directly and throw if missing:
|
||||
|
||||
```typescript
|
||||
pricing: {
|
||||
type: 'custom',
|
||||
getCost: (params, output) => {
|
||||
if (output.creditsUsed == null) {
|
||||
throw new Error('Response missing creditsUsed field')
|
||||
}
|
||||
// $0.001 per credit — from https://example.com/pricing
|
||||
const cost = (output.creditsUsed as number) * 0.001
|
||||
return { cost, metadata: { creditsUsed: output.creditsUsed } }
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**When the API does NOT report cost** — compute it from params/output based on the pricing docs, but still validate the data you depend on:
|
||||
|
||||
```typescript
|
||||
pricing: {
|
||||
type: 'custom',
|
||||
getCost: (params, output) => {
|
||||
if (!Array.isArray(output.searchResults)) {
|
||||
throw new Error('Response missing searchResults, cannot determine cost')
|
||||
}
|
||||
// Serper: 1 credit for <=10 results, 2 credits for >10 — from https://serper.dev/pricing
|
||||
const credits = Number(params.num) > 10 ? 2 : 1
|
||||
return { cost: credits * 0.001, metadata: { credits } }
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**`getCost` must always throw** if it cannot determine cost. Never silently fall back to a default — this would hide billing inaccuracies.
|
||||
|
||||
### Capturing Cost Data from the API
|
||||
|
||||
If the API returns cost info, capture it in `transformResponse` so `getCost` can read it from the output:
|
||||
|
||||
```typescript
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
results: data.results,
|
||||
creditsUsed: data.creditsUsed, // pass through for getCost
|
||||
},
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
For async/polling tools, capture it in `postProcess` when the job completes:
|
||||
|
||||
```typescript
|
||||
if (jobData.status === 'completed') {
|
||||
result.output = {
|
||||
data: jobData.data,
|
||||
creditsUsed: jobData.creditsUsed,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Hide the API Key Field When Hosted
|
||||
|
||||
In the block config (`blocks/blocks/{service}.ts`), add `hideWhenHosted: true` to the API key subblock. This hides the field on hosted Sim since the platform provides the key:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your API key',
|
||||
password: true,
|
||||
required: true,
|
||||
hideWhenHosted: true,
|
||||
},
|
||||
```
|
||||
|
||||
The visibility is controlled by `isSubBlockHidden()` in `lib/workflows/subblocks/visibility.ts`, which checks both the `isHosted` feature flag (`hideWhenHosted`) and optional env var conditions (`hideWhenEnvSet`).
|
||||
|
||||
### Excluding Specific Operations from Hosted Key Support
|
||||
|
||||
When a block has multiple operations but some operations should **not** use a hosted key (e.g., the underlying API is deprecated, unsupported, or too expensive), use the **duplicate apiKey subblock** pattern. This is the same pattern Exa uses for its `research` operation:
|
||||
|
||||
1. **Remove the `hosting` config** from the tool definition for that operation — it must not have a `hosting` object at all.
|
||||
2. **Duplicate the `apiKey` subblock** in the block config with opposing conditions:
|
||||
|
||||
```typescript
|
||||
// API Key — hidden when hosted for operations with hosted key support
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your API key',
|
||||
password: true,
|
||||
required: true,
|
||||
hideWhenHosted: true,
|
||||
condition: { field: 'operation', value: 'unsupported_op', not: true },
|
||||
},
|
||||
// API Key — always visible for unsupported_op (no hosted key support)
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your API key',
|
||||
password: true,
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'unsupported_op' },
|
||||
},
|
||||
```
|
||||
|
||||
Both subblocks share the same `id: 'apiKey'`, so the same value flows to the tool. The conditions ensure only one is visible at a time. The first has `hideWhenHosted: true` and shows for all hosted operations; the second has no `hideWhenHosted` and shows only for the excluded operation — meaning users must always provide their own key for that operation.
|
||||
|
||||
To exclude multiple operations, use an array: `{ field: 'operation', value: ['op_a', 'op_b'] }`.
|
||||
|
||||
**Reference implementations:**
|
||||
- **Exa** (`blocks/blocks/exa.ts`): `research` operation excluded from hosting — lines 309-329
|
||||
- **Google Maps** (`blocks/blocks/google_maps.ts`): `speed_limits` operation excluded from hosting (deprecated Roads API)
|
||||
|
||||
## Step 5: Add to the BYOK Settings UI
|
||||
|
||||
Add an entry to the `PROVIDERS` array in the BYOK settings component so users can bring their own key. You need the service icon from `components/icons.tsx`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'your_service',
|
||||
name: 'Your Service',
|
||||
icon: YourServiceIcon,
|
||||
description: 'What this service does',
|
||||
placeholder: 'Enter your API key',
|
||||
},
|
||||
```
|
||||
|
||||
## Step 6: Summarize Pricing and Throttling Comparison
|
||||
|
||||
After all code changes are complete, output a detailed summary to the user covering:
|
||||
|
||||
### What to include
|
||||
|
||||
1. **API's pricing model** — how the service charges (per token, per credit, per request, etc.), the specific rates found in docs, and whether the API reports cost in responses.
|
||||
2. **Our `getCost` approach** — how we calculate cost, what fields we depend on, and any assumptions or estimates (especially when the API doesn't report exact dollar cost).
|
||||
3. **API's rate limits** — the documented limits (RPM, TPM, concurrent, etc.), which plan tier they apply to, and whether they're per-key or per-account.
|
||||
4. **Our `rateLimit` config** — what we set for `requestsPerMinute` (and dimensions if custom mode), why we chose those values, and how they compare to the API's limits.
|
||||
5. **Key pooling impact** — how many hosted keys we expect, and how round-robin distribution affects the effective per-key rate at the API.
|
||||
6. **Gaps or risks** — anything the API charges for that we don't meter, rate limit dimensions we chose not to enforce, or pricing that may be inaccurate due to variable model/tier costs.
|
||||
|
||||
### Format
|
||||
|
||||
Present this as a structured summary with clear headings. Example:
|
||||
|
||||
```
|
||||
### Pricing
|
||||
- **API charges**: $X per 1M tokens (input), $Y per 1M tokens (output) — varies by model
|
||||
- **Response reports cost?**: No — only token counts in `usage` field
|
||||
- **Our getCost**: Estimates cost at $Z per 1M total tokens based on median model pricing
|
||||
- **Risk**: Actual cost varies by model; our estimate may over/undercharge for cheap/expensive models
|
||||
|
||||
### Throttling
|
||||
- **API limits**: 300 RPM per key (paid tier), 60 RPM (free tier)
|
||||
- **Per-key or per-account**: Per key — more keys = more throughput
|
||||
- **Our config**: 60 RPM per workspace (per_request mode)
|
||||
- **With N keys**: Effective per-key rate is (total RPM across workspaces) / N
|
||||
- **Headroom**: Comfortable — even 10 active workspaces at full rate = 600 RPM / 3 keys = 200 RPM per key, under the 300 RPM API limit
|
||||
```
|
||||
|
||||
This summary helps reviewers verify that the pricing and rate limiting are well-calibrated and surfaces any risks that need monitoring.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Provider added to `BYOKProviderId` in `tools/types.ts`
|
||||
- [ ] Provider added to `VALID_PROVIDERS` in the BYOK keys API route
|
||||
- [ ] API pricing docs researched — understand per-unit cost and whether the API reports cost in responses
|
||||
- [ ] API rate limits researched — understand RPM/TPM limits, per-key vs per-account, and plan tiers
|
||||
- [ ] `hosting` config added to the tool with `envKeyPrefix`, `apiKeyParam`, `byokProviderId`, `pricing`, and `rateLimit`
|
||||
- [ ] `getCost` throws if required cost data is missing from the response
|
||||
- [ ] Cost data captured in `transformResponse` or `postProcess` if API provides it
|
||||
- [ ] `hideWhenHosted: true` added to the API key subblock in the block config
|
||||
- [ ] Provider entry added to the BYOK settings UI with icon and description
|
||||
- [ ] Env vars documented: `{PREFIX}_COUNT` and `{PREFIX}_1..N`
|
||||
- [ ] Pricing and throttling summary provided to reviewer
|
||||
759
.cursor/commands/add-integration.md
Normal file
759
.cursor/commands/add-integration.md
Normal file
@@ -0,0 +1,759 @@
|
||||
# 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, IntegrationType } 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',
|
||||
integrationType: IntegrationType.X, // Primary category (see IntegrationType enum)
|
||||
tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type)
|
||||
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`
|
||||
- [ ] Set `integrationType` to the correct `IntegrationType` enum value
|
||||
- [ ] Set `tags` array with all applicable `IntegrationTag` values
|
||||
- [ ] 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`
|
||||
316
.cursor/commands/add-tools.md
Normal file
316
.cursor/commands/add-tools.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# 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)
|
||||
353
.cursor/commands/add-trigger.md
Normal file
353
.cursor/commands/add-trigger.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# Add Trigger
|
||||
|
||||
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
|
||||
|
||||
1. Research what webhook events the service supports
|
||||
2. Create the trigger files using the generic builder
|
||||
3. Create a provider handler if custom auth, formatting, or subscriptions are needed
|
||||
4. Register triggers and connect them to the block
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
apps/sim/triggers/{service}/
|
||||
├── index.ts # Barrel exports
|
||||
├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs)
|
||||
├── {event_a}.ts # Primary trigger (includes dropdown)
|
||||
├── {event_b}.ts # Secondary trigger (no dropdown)
|
||||
└── webhook.ts # Generic webhook trigger (optional, for "all events")
|
||||
|
||||
apps/sim/lib/webhooks/
|
||||
├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl)
|
||||
├── providers/
|
||||
│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions)
|
||||
│ ├── types.ts # WebhookProviderHandler interface
|
||||
│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes)
|
||||
│ └── registry.ts # Handler map + default handler
|
||||
```
|
||||
|
||||
## Step 1: Create `utils.ts`
|
||||
|
||||
This file contains all service-specific helpers used by triggers.
|
||||
|
||||
```typescript
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { TriggerOutput } from '@/triggers/types'
|
||||
|
||||
export const {service}TriggerOptions = [
|
||||
{ label: 'Event A', id: '{service}_event_a' },
|
||||
{ label: 'Event B', id: '{service}_event_b' },
|
||||
]
|
||||
|
||||
export function {service}SetupInstructions(eventType: string): string {
|
||||
const instructions = [
|
||||
'Copy the <strong>Webhook URL</strong> above',
|
||||
'Go to <strong>{Service} Settings > Webhooks</strong>',
|
||||
`Select the <strong>${eventType}</strong> event type`,
|
||||
'Paste the webhook URL and save',
|
||||
'Click "Save" above to activate your trigger',
|
||||
]
|
||||
return instructions
|
||||
.map((instruction, index) =>
|
||||
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
|
||||
return [
|
||||
{
|
||||
id: 'projectId',
|
||||
title: 'Project ID (Optional)',
|
||||
type: 'short-input',
|
||||
placeholder: 'Leave empty for all projects',
|
||||
mode: 'trigger',
|
||||
condition: { field: 'selectedTriggerId', value: triggerId },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function build{Service}Outputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
eventType: { type: 'string', description: 'The type of event' },
|
||||
resourceId: { type: 'string', description: 'ID of the affected resource' },
|
||||
resource: {
|
||||
id: { type: 'string', description: 'Resource ID' },
|
||||
name: { type: 'string', description: 'Resource name' },
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2: Create Trigger Files
|
||||
|
||||
**Primary trigger** — MUST include `includeDropdown: true`:
|
||||
|
||||
```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'
|
||||
|
||||
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,
|
||||
setupInstructions: {service}SetupInstructions('Event A'),
|
||||
extraFields: build{Service}ExtraFields('{service}_event_a'),
|
||||
}),
|
||||
outputs: build{Service}Outputs(),
|
||||
webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
|
||||
}
|
||||
```
|
||||
|
||||
**Secondary triggers** — NO `includeDropdown` (it's already in the primary):
|
||||
|
||||
```typescript
|
||||
export const {service}EventBTrigger: TriggerConfig = {
|
||||
// Same as above but: id: '{service}_event_b', no includeDropdown
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Register and Wire
|
||||
|
||||
### `apps/sim/triggers/{service}/index.ts`
|
||||
|
||||
```typescript
|
||||
export { {service}EventATrigger } from './event_a'
|
||||
export { {service}EventBTrigger } from './event_b'
|
||||
```
|
||||
|
||||
### `apps/sim/triggers/registry.ts`
|
||||
|
||||
```typescript
|
||||
import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}'
|
||||
|
||||
export const TRIGGER_REGISTRY: TriggerRegistry = {
|
||||
// ... existing ...
|
||||
{service}_event_a: {service}EventATrigger,
|
||||
{service}_event_b: {service}EventBTrigger,
|
||||
}
|
||||
```
|
||||
|
||||
### Block file (`apps/sim/blocks/blocks/{service}.ts`)
|
||||
|
||||
```typescript
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
export const {Service}Block: BlockConfig = {
|
||||
// ...
|
||||
triggers: {
|
||||
enabled: true,
|
||||
available: ['{service}_event_a', '{service}_event_b'],
|
||||
},
|
||||
subBlocks: [
|
||||
// Regular tool subBlocks first...
|
||||
...getTrigger('{service}_event_a').subBlocks,
|
||||
...getTrigger('{service}_event_b').subBlocks,
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Handler
|
||||
|
||||
All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`.
|
||||
|
||||
### When to Create a Handler
|
||||
|
||||
| Behavior | Method | Examples |
|
||||
|---|---|---|
|
||||
| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform |
|
||||
| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms |
|
||||
| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot |
|
||||
| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira |
|
||||
| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby |
|
||||
| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable |
|
||||
| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable |
|
||||
| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams |
|
||||
| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams |
|
||||
|
||||
If none apply, you don't need a handler. The default handler provides bearer token auth.
|
||||
|
||||
### Example Handler
|
||||
|
||||
```typescript
|
||||
import crypto from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import type { EventMatchContext, FormatInputContext, FormatInputResult, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:{Service}')
|
||||
|
||||
function validate{Service}Signature(secret: string, signature: string, body: string): boolean {
|
||||
if (!secret || !signature || !body) return false
|
||||
const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
|
||||
return safeCompare(computed, signature)
|
||||
}
|
||||
|
||||
export const {service}Handler: WebhookProviderHandler = {
|
||||
verifyAuth: createHmacVerifier({
|
||||
configKey: 'webhookSecret',
|
||||
headerName: 'X-{Service}-Signature',
|
||||
validateFn: validate{Service}Signature,
|
||||
providerLabel: '{Service}',
|
||||
}),
|
||||
|
||||
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
|
||||
const triggerId = providerConfig.triggerId as string | undefined
|
||||
if (triggerId && triggerId !== '{service}_webhook') {
|
||||
const { is{Service}EventMatch } = await import('@/triggers/{service}/utils')
|
||||
if (!is{Service}EventMatch(triggerId, body as Record<string, unknown>)) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
return {
|
||||
input: {
|
||||
eventType: b.type,
|
||||
resourceId: (b.data as Record<string, unknown>)?.id || '',
|
||||
resource: b.data,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
extractIdempotencyId(body: unknown) {
|
||||
const obj = body as Record<string, unknown>
|
||||
return obj.id && obj.type ? `${obj.type}:${obj.id}` : null
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Register the Handler
|
||||
|
||||
In `apps/sim/lib/webhooks/providers/registry.ts`:
|
||||
|
||||
```typescript
|
||||
import { {service}Handler } from '@/lib/webhooks/providers/{service}'
|
||||
|
||||
const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
|
||||
// ... existing (alphabetical) ...
|
||||
{service}: {service}Handler,
|
||||
}
|
||||
```
|
||||
|
||||
## Output Alignment (Critical)
|
||||
|
||||
There are two sources of truth that **MUST be aligned**:
|
||||
|
||||
1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown)
|
||||
2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data
|
||||
|
||||
If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover.
|
||||
|
||||
**Rules for `formatInput`:**
|
||||
- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly
|
||||
- Return `{ input: ..., skip: { message: '...' } }` to skip execution
|
||||
- No wrapper objects or duplication
|
||||
- Use `null` for missing optional data
|
||||
|
||||
## Automatic Webhook Registration
|
||||
|
||||
If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**.
|
||||
|
||||
```typescript
|
||||
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
|
||||
import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types'
|
||||
|
||||
export const {service}Handler: WebhookProviderHandler = {
|
||||
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
|
||||
const config = getProviderConfig(ctx.webhook)
|
||||
const apiKey = config.apiKey as string
|
||||
if (!apiKey) throw new Error('{Service} API Key is required.')
|
||||
|
||||
const res = await fetch('https://api.{service}.com/webhooks', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`{Service} error: ${res.status}`)
|
||||
const { id } = (await res.json()) as { id: string }
|
||||
return { providerConfigUpdates: { externalId: id } }
|
||||
},
|
||||
|
||||
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
|
||||
const config = getProviderConfig(ctx.webhook)
|
||||
const { apiKey, externalId } = config as { apiKey?: string; externalId?: string }
|
||||
if (!apiKey || !externalId) return
|
||||
await fetch(`https://api.{service}.com/webhooks/${externalId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}).catch(() => {})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Throw from `createSubscription` — orchestration rolls back the DB webhook
|
||||
- Never throw from `deleteSubscription` — log non-fatally
|
||||
- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig`
|
||||
- Add `apiKey` field to `build{Service}ExtraFields` with `password: true`
|
||||
|
||||
## Trigger Outputs Schema
|
||||
|
||||
Trigger outputs use the same schema as block outputs (NOT tool outputs).
|
||||
|
||||
**Supported:** `type` + `description` for leaf fields, nested objects for complex data.
|
||||
**NOT supported:** `optional: true`, `items` (those are tool-output-only features).
|
||||
|
||||
```typescript
|
||||
export function buildOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
eventType: { type: 'string', description: 'Event type' },
|
||||
timestamp: { type: 'string', description: 'When it occurred' },
|
||||
payload: { type: 'json', description: 'Full event payload' },
|
||||
resource: {
|
||||
id: { type: 'string', description: 'Resource ID' },
|
||||
name: { type: 'string', description: 'Resource name' },
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
### Trigger Definition
|
||||
- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders
|
||||
- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT
|
||||
- [ ] All triggers use `buildTriggerSubBlocks` helper
|
||||
- [ ] Created `index.ts` barrel export
|
||||
|
||||
### Registration
|
||||
- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY`
|
||||
- [ ] Block has `triggers.enabled: true` and lists all trigger IDs in `triggers.available`
|
||||
- [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks`
|
||||
|
||||
### Provider Handler (if needed)
|
||||
- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts`
|
||||
- [ ] Registered in `providers/registry.ts` (alphabetical)
|
||||
- [ ] Signature validator is a private function inside the handler file
|
||||
- [ ] `formatInput` output keys match trigger `outputs` exactly
|
||||
- [ ] Event matching uses dynamic `await import()` for trigger utils
|
||||
|
||||
### Auto Registration (if supported)
|
||||
- [ ] `createSubscription` and `deleteSubscription` on the handler
|
||||
- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
|
||||
- [ ] API key field uses `password: true`
|
||||
|
||||
### Testing
|
||||
- [ ] `bun run type-check` passes
|
||||
- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys
|
||||
- [ ] Trigger UI shows correctly in the block
|
||||
311
.cursor/commands/validate-connector.md
Normal file
311
.cursor/commands/validate-connector.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# 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
|
||||
284
.cursor/commands/validate-integration.md
Normal file
284
.cursor/commands/validate-integration.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# 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
|
||||
207
.cursor/commands/validate-trigger.md
Normal file
207
.cursor/commands/validate-trigger.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Validate Trigger
|
||||
|
||||
You are an expert auditor for Sim webhook triggers. Your job is to validate that an existing trigger implementation is correct, complete, secure, and aligned across all layers.
|
||||
|
||||
## Your Task
|
||||
|
||||
1. Read the service's webhook/API documentation (via WebFetch)
|
||||
2. Read every trigger file, provider handler, and registry entry
|
||||
3. Cross-reference against the API docs and Sim conventions
|
||||
4. Report all issues grouped by severity (critical, warning, suggestion)
|
||||
5. Fix all issues after reporting them
|
||||
|
||||
## Step 1: Gather All Files
|
||||
|
||||
Read **every** file for the trigger — do not skip any:
|
||||
|
||||
```
|
||||
apps/sim/triggers/{service}/ # All trigger files, utils.ts, index.ts
|
||||
apps/sim/lib/webhooks/providers/{service}.ts # Provider handler (if exists)
|
||||
apps/sim/lib/webhooks/providers/registry.ts # Handler registry
|
||||
apps/sim/triggers/registry.ts # Trigger registry
|
||||
apps/sim/blocks/blocks/{service}.ts # Block definition (trigger wiring)
|
||||
```
|
||||
|
||||
Also read for reference:
|
||||
```
|
||||
apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface
|
||||
apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.)
|
||||
apps/sim/lib/webhooks/provider-subscription-utils.ts # Subscription helpers
|
||||
apps/sim/lib/webhooks/processor.ts # Central webhook processor
|
||||
```
|
||||
|
||||
## Step 2: Pull API Documentation
|
||||
|
||||
Fetch the service's official webhook documentation. This is the **source of truth** for:
|
||||
- Webhook event types and payload shapes
|
||||
- Signature/auth verification method (HMAC algorithm, header names, secret format)
|
||||
- Challenge/verification handshake requirements
|
||||
- Webhook subscription API (create/delete endpoints, if applicable)
|
||||
- Retry behavior and delivery guarantees
|
||||
|
||||
## Step 3: Validate Trigger Definitions
|
||||
|
||||
### utils.ts
|
||||
- [ ] `{service}TriggerOptions` lists all trigger IDs accurately
|
||||
- [ ] `{service}SetupInstructions` provides clear, correct steps for the service
|
||||
- [ ] `build{Service}ExtraFields` includes relevant filter/config fields with correct `condition`
|
||||
- [ ] Output builders expose all meaningful fields from the webhook payload
|
||||
- [ ] Output builders do NOT use `optional: true` or `items` (tool-output-only features)
|
||||
- [ ] Nested output objects correctly model the payload structure
|
||||
|
||||
### Trigger Files
|
||||
- [ ] Exactly one primary trigger has `includeDropdown: true`
|
||||
- [ ] All secondary triggers do NOT have `includeDropdown`
|
||||
- [ ] All triggers use `buildTriggerSubBlocks` helper (not hand-rolled subBlocks)
|
||||
- [ ] Every trigger's `id` matches the convention `{service}_{event_name}`
|
||||
- [ ] Every trigger's `provider` matches the service name used in the handler registry
|
||||
- [ ] `index.ts` barrel exports all triggers
|
||||
|
||||
### Trigger ↔ Provider Alignment (CRITICAL)
|
||||
- [ ] Every trigger ID referenced in `matchEvent` logic exists in `{service}TriggerOptions`
|
||||
- [ ] Event matching logic in the provider correctly maps trigger IDs to service event types
|
||||
- [ ] Event matching logic in `is{Service}EventMatch` (if exists) correctly identifies events per the API docs
|
||||
|
||||
## Step 4: Validate Provider Handler
|
||||
|
||||
### Auth Verification
|
||||
- [ ] `verifyAuth` correctly validates webhook signatures per the service's documentation
|
||||
- [ ] HMAC algorithm matches (SHA-1, SHA-256, SHA-512)
|
||||
- [ ] Signature header name matches the API docs exactly
|
||||
- [ ] Signature format is handled (raw hex, `sha256=` prefix, base64, etc.)
|
||||
- [ ] Uses `safeCompare` for timing-safe comparison (no `===`)
|
||||
- [ ] If `webhookSecret` is required, handler rejects when it's missing (fail-closed)
|
||||
- [ ] Signature is computed over raw body (not parsed JSON)
|
||||
|
||||
### Event Matching
|
||||
- [ ] `matchEvent` returns `boolean` (not `NextResponse` or other values)
|
||||
- [ ] Challenge/verification events are excluded from matching (e.g., `endpoint.url_validation`)
|
||||
- [ ] When `triggerId` is a generic webhook ID, all events pass through
|
||||
- [ ] When `triggerId` is specific, only matching events pass
|
||||
- [ ] Event matching logic uses dynamic `await import()` for trigger utils (avoids circular deps)
|
||||
|
||||
### formatInput (CRITICAL)
|
||||
- [ ] Every key in the `formatInput` return matches a key in the trigger `outputs` schema
|
||||
- [ ] Every key in the trigger `outputs` schema is populated by `formatInput`
|
||||
- [ ] No extra undeclared keys that users can't discover in the UI
|
||||
- [ ] No wrapper objects (`webhook: { ... }`, `{service}: { ... }`)
|
||||
- [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`)
|
||||
- [ ] `null` is used for missing optional fields (not empty strings or empty objects)
|
||||
- [ ] Returns `{ input: { ... } }` — not a bare object
|
||||
|
||||
### Idempotency
|
||||
- [ ] `extractIdempotencyId` returns a stable, unique key per delivery
|
||||
- [ ] Uses provider-specific delivery IDs when available (e.g., `X-Request-Id`, `Linear-Delivery`, `svix-id`)
|
||||
- [ ] Falls back to content-based ID (e.g., `${type}:${id}`) when no delivery header exists
|
||||
- [ ] Does NOT include timestamps in the idempotency key (would break dedup on retries)
|
||||
|
||||
### Challenge Handling (if applicable)
|
||||
- [ ] `handleChallenge` correctly implements the service's URL verification handshake
|
||||
- [ ] Returns the expected response format per the API docs
|
||||
- [ ] Env-backed secrets are resolved via `resolveEnvVarsInObject` if needed
|
||||
|
||||
## Step 5: Validate Automatic Subscription Lifecycle
|
||||
|
||||
If the service supports programmatic webhook creation:
|
||||
|
||||
### createSubscription
|
||||
- [ ] Calls the correct API endpoint to create a webhook
|
||||
- [ ] Sends the correct event types/filters
|
||||
- [ ] Passes the notification URL from `getNotificationUrl(ctx.webhook)`
|
||||
- [ ] Returns `{ providerConfigUpdates: { externalId } }` with the external webhook ID
|
||||
- [ ] Throws on failure (orchestration handles rollback)
|
||||
- [ ] Provides user-friendly error messages (401 → "Invalid API Key", etc.)
|
||||
|
||||
### deleteSubscription
|
||||
- [ ] Calls the correct API endpoint to delete the webhook
|
||||
- [ ] Handles 404 gracefully (webhook already deleted)
|
||||
- [ ] Never throws — catches errors and logs non-fatally
|
||||
- [ ] Skips gracefully when `apiKey` or `externalId` is missing
|
||||
|
||||
### Orchestration Isolation
|
||||
- [ ] NO provider-specific logic in `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
|
||||
- [ ] All subscription logic lives on the handler (`createSubscription`/`deleteSubscription`)
|
||||
|
||||
## Step 6: Validate Registration and Block Wiring
|
||||
|
||||
### Trigger Registry (`triggers/registry.ts`)
|
||||
- [ ] All triggers are imported and registered
|
||||
- [ ] Registry keys match trigger IDs exactly
|
||||
- [ ] No orphaned entries (triggers that don't exist)
|
||||
|
||||
### Provider Handler Registry (`providers/registry.ts`)
|
||||
- [ ] Handler is imported and registered (if handler exists)
|
||||
- [ ] Registry key matches the `provider` field on the trigger configs
|
||||
- [ ] Entries are in alphabetical order
|
||||
|
||||
### Block Wiring (`blocks/blocks/{service}.ts`)
|
||||
- [ ] Block has `triggers.enabled: true`
|
||||
- [ ] `triggers.available` lists all trigger IDs
|
||||
- [ ] All trigger subBlocks are spread into `subBlocks`: `...getTrigger('id').subBlocks`
|
||||
- [ ] No trigger IDs in `triggers.available` that aren't in the registry
|
||||
- [ ] No trigger subBlocks spread that aren't in `triggers.available`
|
||||
|
||||
## Step 7: Validate Security
|
||||
|
||||
- [ ] Webhook secrets are never logged (not even at debug level)
|
||||
- [ ] Auth verification runs before any event processing
|
||||
- [ ] No secret comparison uses `===` (must use `safeCompare` or `crypto.timingSafeEqual`)
|
||||
- [ ] Timestamp/replay protection is reasonable (not too tight for retries, not too loose for security)
|
||||
- [ ] Raw body is used for signature verification (not re-serialized JSON)
|
||||
|
||||
## Step 8: Report and Fix
|
||||
|
||||
### Report Format
|
||||
|
||||
Group findings by severity:
|
||||
|
||||
**Critical** (runtime errors, security issues, or data loss):
|
||||
- Wrong HMAC algorithm or header name
|
||||
- `formatInput` keys don't match trigger `outputs`
|
||||
- Missing `verifyAuth` when the service sends signed webhooks
|
||||
- `matchEvent` returns non-boolean values
|
||||
- Provider-specific logic leaking into shared orchestration files
|
||||
- Trigger IDs mismatch between trigger files, registry, and block
|
||||
- `createSubscription` calling wrong API endpoint
|
||||
- Auth comparison using `===` instead of `safeCompare`
|
||||
|
||||
**Warning** (convention violations or usability issues):
|
||||
- Missing `extractIdempotencyId` when the service provides delivery IDs
|
||||
- Timestamps in idempotency keys (breaks dedup on retries)
|
||||
- Missing challenge handling when the service requires URL verification
|
||||
- Output schema missing fields that `formatInput` returns (undiscoverable data)
|
||||
- Overly tight timestamp skew window that rejects legitimate retries
|
||||
- `matchEvent` not filtering challenge/verification events
|
||||
- Setup instructions missing important steps
|
||||
|
||||
**Suggestion** (minor improvements):
|
||||
- More specific output field descriptions
|
||||
- Additional output fields that could be exposed
|
||||
- Better error messages in `createSubscription`
|
||||
- Logging improvements
|
||||
|
||||
### 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 type-check` passes
|
||||
2. Re-read all modified files to verify fixes are correct
|
||||
3. Provider handler tests pass (if they exist): `bun test {service}`
|
||||
|
||||
## Checklist Summary
|
||||
|
||||
- [ ] Read all trigger files, provider handler, types, registries, and block
|
||||
- [ ] Pulled and read official webhook/API documentation
|
||||
- [ ] Validated trigger definitions: options, instructions, extra fields, outputs
|
||||
- [ ] Validated primary/secondary trigger distinction (`includeDropdown`)
|
||||
- [ ] Validated provider handler: auth, matchEvent, formatInput, idempotency
|
||||
- [ ] Validated output alignment: every `outputs` key ↔ every `formatInput` key
|
||||
- [ ] Validated subscription lifecycle: createSubscription, deleteSubscription, no shared-file edits
|
||||
- [ ] Validated registration: trigger registry, handler registry, block wiring
|
||||
- [ ] Validated security: safe comparison, no secret logging, replay protection
|
||||
- [ ] Reported all issues grouped by severity
|
||||
- [ ] Fixed all critical and warning issues
|
||||
- [ ] `bun run type-check` passes after fixes
|
||||
12
.cursor/commands/you-might-not-need-an-effect.md
Normal file
12
.cursor/commands/you-might-not-need-an-effect.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# You Might Not Need an Effect
|
||||
|
||||
Arguments:
|
||||
- scope: what to analyze (default: your current changes). Examples: "diff to main", "PR #123", "src/components/", "whole codebase"
|
||||
- fix: whether to apply fixes (default: true). Set to false to only propose changes.
|
||||
|
||||
User arguments: $ARGUMENTS
|
||||
|
||||
Steps:
|
||||
1. Read https://react.dev/learn/you-might-not-need-an-effect to understand the guidelines
|
||||
2. Analyze the specified scope for useEffect anti-patterns
|
||||
3. If fix=true, apply the fixes. If fix=false, propose the fixes without applying.
|
||||
@@ -16,5 +16,26 @@ Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||
## Styling
|
||||
Never update global styles. Keep all styling local to components.
|
||||
|
||||
## ID Generation
|
||||
Never use `crypto.randomUUID()`, `nanoid`, or the `uuid` package directly. Use the utilities from `@/lib/core/utils/uuid`:
|
||||
|
||||
- `generateId()` — UUID v4, use by default
|
||||
- `generateShortId(size?)` — short URL-safe ID (default 21 chars), for compact identifiers
|
||||
|
||||
Both use `crypto.getRandomValues()` under the hood and work in all contexts including non-secure (HTTP) browsers.
|
||||
|
||||
```typescript
|
||||
// ✗ Bad
|
||||
import { nanoid } from 'nanoid'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// ✓ Good
|
||||
import { generateId, generateShortId } from '@/lib/core/utils/uuid'
|
||||
const uuid = generateId()
|
||||
const shortId = generateShortId()
|
||||
const tiny = generateShortId(8)
|
||||
```
|
||||
|
||||
## Package Manager
|
||||
Use `bun` and `bunx`, not `npm` and `npx`.
|
||||
|
||||
@@ -7,6 +7,7 @@ You are a professional software engineer. All code must follow best practices: a
|
||||
- **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
|
||||
- **ID Generation**: Never use `crypto.randomUUID()`, `nanoid`, or `uuid` package. Use `generateId()` (UUID v4) or `generateShortId()` (compact) from `@/lib/core/utils/uuid`
|
||||
- **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx`
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -7,6 +7,7 @@ You are a professional software engineer. All code must follow best practices: a
|
||||
- **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
|
||||
- **ID Generation**: Never use `crypto.randomUUID()`, `nanoid`, or `uuid` package. Use `generateId()` (UUID v4) or `generateShortId()` (compact) from `@/lib/core/utils/uuid`
|
||||
- **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx`
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -6,11 +6,9 @@ import { createAPIPage } from 'fumadocs-openapi/ui'
|
||||
import { Pre } from 'fumadocs-ui/components/codeblock'
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx'
|
||||
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/page'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { PageFooter } from '@/components/docs-layout/page-footer'
|
||||
import { PageNavigationArrows } from '@/components/docs-layout/page-navigation-arrows'
|
||||
import { TOCFooter } from '@/components/docs-layout/toc-footer'
|
||||
import { LLMCopyButton } from '@/components/page-actions'
|
||||
import { StructuredData } from '@/components/structured-data'
|
||||
import { CodeBlock } from '@/components/ui/code-block'
|
||||
@@ -23,6 +21,15 @@ import { type PageData, source } from '@/lib/source'
|
||||
const SUPPORTED_LANGUAGES: Set<string> = new Set(i18n.languages)
|
||||
const BASE_URL = 'https://docs.sim.ai'
|
||||
|
||||
const OG_LOCALE_MAP: Record<string, string> = {
|
||||
en: 'en_US',
|
||||
es: 'es_ES',
|
||||
fr: 'fr_FR',
|
||||
de: 'de_DE',
|
||||
ja: 'ja_JP',
|
||||
zh: 'zh_CN',
|
||||
}
|
||||
|
||||
function resolveLangAndSlug(params: { slug?: string[]; lang: string }) {
|
||||
const isValidLang = SUPPORTED_LANGUAGES.has(params.lang)
|
||||
const lang = isValidLang ? params.lang : 'en'
|
||||
@@ -120,101 +127,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
}
|
||||
|
||||
const breadcrumbs = generateBreadcrumbs()
|
||||
|
||||
const CustomFooter = () => (
|
||||
<div className='mt-12'>
|
||||
<div className='flex items-center justify-between py-8'>
|
||||
{neighbours?.previous ? (
|
||||
<Link
|
||||
href={neighbours.previous.url}
|
||||
className='group flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
<ChevronLeft className='group-hover:-translate-x-1 h-4 w-4 transition-transform' />
|
||||
<span className='font-medium'>{neighbours.previous.name}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{neighbours?.next ? (
|
||||
<Link
|
||||
href={neighbours.next.url}
|
||||
className='group flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
<span className='font-medium'>{neighbours.next.name}</span>
|
||||
<ChevronRight className='h-4 w-4 transition-transform group-hover:translate-x-1' />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='border-border border-t' />
|
||||
|
||||
<div className='flex items-center gap-4 py-6'>
|
||||
<Link
|
||||
href='https://x.com/simdotai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label='X (Twitter)'
|
||||
>
|
||||
<div
|
||||
className='h-5 w-5 bg-gray-400 transition-colors hover:bg-gray-500 dark:bg-gray-500 dark:hover:bg-gray-400'
|
||||
style={{
|
||||
maskImage:
|
||||
"url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3E%3Cpath d=%22M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z%22/%3E%3C/svg%3E')",
|
||||
maskRepeat: 'no-repeat',
|
||||
maskPosition: 'center center',
|
||||
WebkitMaskImage:
|
||||
"url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3E%3Cpath d=%22M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z%22/%3E%3C/svg%3E')",
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
WebkitMaskPosition: 'center center',
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label='GitHub'
|
||||
>
|
||||
<div
|
||||
className='h-5 w-5 bg-gray-400 transition-colors hover:bg-gray-500 dark:bg-gray-500 dark:hover:bg-gray-400'
|
||||
style={{
|
||||
maskImage:
|
||||
"url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3E%3Cpath d=%22M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z%22/%3E%3C/svg%3E')",
|
||||
maskRepeat: 'no-repeat',
|
||||
maskPosition: 'center center',
|
||||
WebkitMaskImage:
|
||||
"url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3E%3Cpath d=%22M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z%22/%3E%3C/svg%3E')",
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
WebkitMaskPosition: 'center center',
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href='https://discord.gg/Hr4UWYEcTT'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label='Discord'
|
||||
>
|
||||
<div
|
||||
className='h-5 w-5 bg-gray-400 transition-colors hover:bg-gray-500 dark:bg-gray-500 dark:hover:bg-gray-400'
|
||||
style={{
|
||||
maskImage:
|
||||
"url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3E%3Cpath d=%22M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z%22/%3E%3C/svg%3E')",
|
||||
maskRepeat: 'no-repeat',
|
||||
maskPosition: 'center center',
|
||||
WebkitMaskImage:
|
||||
"url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3E%3Cpath d=%22M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z%22/%3E%3C/svg%3E')",
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
WebkitMaskPosition: 'center center',
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const footer = <PageFooter previous={neighbours?.previous} next={neighbours?.next} />
|
||||
|
||||
if (isOpenAPI && data.getAPIPageProps) {
|
||||
const apiProps = data.getAPIPageProps()
|
||||
@@ -233,7 +146,6 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
lang={lang}
|
||||
breadcrumb={breadcrumbs}
|
||||
/>
|
||||
<style>{`#nd-page { grid-column: main-start / toc-end !important; max-width: 1400px !important; }`}</style>
|
||||
<DocsPage
|
||||
toc={data.toc}
|
||||
breadcrumb={{
|
||||
@@ -249,7 +161,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
}}
|
||||
footer={{
|
||||
enabled: true,
|
||||
component: <CustomFooter />,
|
||||
component: footer,
|
||||
}}
|
||||
>
|
||||
<div className='api-page-header relative mt-6 sm:mt-0'>
|
||||
@@ -259,7 +171,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
</div>
|
||||
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
|
||||
</div>
|
||||
<DocsTitle>{data.title}</DocsTitle>
|
||||
<DocsTitle className='mb-2'>{data.title}</DocsTitle>
|
||||
<DocsDescription>{data.description}</DocsDescription>
|
||||
</div>
|
||||
<DocsBody>
|
||||
@@ -291,7 +203,6 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
tableOfContent={{
|
||||
style: 'clerk',
|
||||
enabled: true,
|
||||
footer: <TOCFooter />,
|
||||
single: false,
|
||||
}}
|
||||
tableOfContentPopover={{
|
||||
@@ -300,7 +211,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
}}
|
||||
footer={{
|
||||
enabled: true,
|
||||
component: <CustomFooter />,
|
||||
component: footer,
|
||||
}}
|
||||
>
|
||||
<div className='relative mt-6 sm:mt-0'>
|
||||
@@ -310,7 +221,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
|
||||
</div>
|
||||
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
|
||||
</div>
|
||||
<DocsTitle>{data.title}</DocsTitle>
|
||||
<DocsTitle className='mb-2'>{data.title}</DocsTitle>
|
||||
<DocsDescription>{data.description}</DocsDescription>
|
||||
</div>
|
||||
<DocsBody>
|
||||
@@ -393,10 +304,10 @@ export async function generateMetadata(props: {
|
||||
url: fullUrl,
|
||||
siteName: 'Sim Documentation',
|
||||
type: 'article',
|
||||
locale: lang === 'en' ? 'en_US' : `${lang}_${lang.toUpperCase()}`,
|
||||
alternateLocale: ['en', 'es', 'fr', 'de', 'ja', 'zh']
|
||||
locale: OG_LOCALE_MAP[lang] ?? 'en_US',
|
||||
alternateLocale: i18n.languages
|
||||
.filter((l) => l !== lang)
|
||||
.map((l) => (l === 'en' ? 'en_US' : `${l}_${l.toUpperCase()}`)),
|
||||
.map((l) => OG_LOCALE_MAP[l] ?? 'en_US'),
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
@@ -416,17 +327,6 @@ export async function generateMetadata(props: {
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
canonical: fullUrl,
|
||||
alternates: {
|
||||
canonical: fullUrl,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SidebarSeparator,
|
||||
} from '@/components/docs-layout/sidebar-components'
|
||||
import { Navbar } from '@/components/navbar/navbar'
|
||||
import { AnimatedBlocks } from '@/components/ui/animated-blocks'
|
||||
import { SimLogoFull } from '@/components/ui/sim-logo'
|
||||
import { i18n } from '@/lib/i18n'
|
||||
import { source } from '@/lib/source'
|
||||
@@ -103,13 +102,12 @@ export default async function Layout({ children, params }: LayoutProps) {
|
||||
</head>
|
||||
<body className='flex min-h-screen flex-col font-sans'>
|
||||
<Script src='https://assets.onedollarstats.com/stonks.js' strategy='lazyOnload' />
|
||||
<AnimatedBlocks />
|
||||
<RootProvider i18n={provider(lang)}>
|
||||
<Navbar />
|
||||
<DocsLayout
|
||||
tree={source.pageTree[lang]}
|
||||
nav={{
|
||||
title: <SimLogoFull className='h-7 w-auto' />,
|
||||
title: <SimLogoFull className='h-[22px] w-auto' />,
|
||||
}}
|
||||
sidebar={{
|
||||
tabs: false,
|
||||
|
||||
BIN
apps/docs/app/favicon.ico
Normal file
BIN
apps/docs/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
|
||||
],
|
||||
themeColor: '#000000',
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
@@ -49,15 +46,7 @@ export const metadata = {
|
||||
classification: 'Developer Documentation',
|
||||
manifest: '/favicon/site.webmanifest',
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' },
|
||||
{ url: '/favicon/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
|
||||
{ url: '/favicon/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/favicon/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ url: '/favicon/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
apple: '/favicon/apple-touch-icon.png',
|
||||
shortcut: '/icon.svg',
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
@@ -68,9 +57,7 @@ export const metadata = {
|
||||
telephone: false,
|
||||
},
|
||||
other: {
|
||||
'apple-mobile-web-app-capable': 'yes',
|
||||
'mobile-web-app-capable': 'yes',
|
||||
'msapplication-TileColor': '#33C482',
|
||||
'msapplication-TileColor': '#000000',
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
|
||||
@@ -4,7 +4,6 @@ export async function GET() {
|
||||
const baseUrl = 'https://docs.sim.ai'
|
||||
|
||||
const robotsTxt = `# Robots.txt for Sim Documentation
|
||||
# Generated on ${new Date().toISOString()}
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { i18n } from '@/lib/i18n'
|
||||
import { source } from '@/lib/source'
|
||||
|
||||
export const revalidate = false
|
||||
export const revalidate = 3600
|
||||
|
||||
export async function GET() {
|
||||
const baseUrl = 'https://docs.sim.ai'
|
||||
@@ -28,8 +28,6 @@ export async function GET() {
|
||||
|
||||
return ` <url>
|
||||
<loc>${url}</loc>
|
||||
<lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>${getPriority(urlWithoutLang)}</priority>
|
||||
${i18n.languages.length > 1 ? generateAlternateLinks(baseUrl, urlWithoutLang) : ''}
|
||||
</url>`
|
||||
@@ -51,7 +49,7 @@ ${urls}
|
||||
}
|
||||
|
||||
function generateAlternateLinks(baseUrl: string, urlWithoutLang: string): string {
|
||||
return i18n.languages
|
||||
const langLinks = i18n.languages
|
||||
.map((lang) => {
|
||||
const url =
|
||||
lang === i18n.defaultLanguage
|
||||
@@ -60,4 +58,5 @@ function generateAlternateLinks(baseUrl: string, urlWithoutLang: string): string
|
||||
return ` <xhtml:link rel="alternate" hreflang="${lang}" href="${url}" />`
|
||||
})
|
||||
.join('\n')
|
||||
return `${langLinks}\n <xhtml:link rel="alternate" hreflang="x-default" href="${baseUrl}${urlWithoutLang}" />`
|
||||
}
|
||||
|
||||
102
apps/docs/components/docs-layout/page-footer.tsx
Normal file
102
apps/docs/components/docs-layout/page-footer.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface PageNeighbour {
|
||||
url: string
|
||||
name: ReactNode
|
||||
}
|
||||
|
||||
interface PageFooterProps {
|
||||
previous?: PageNeighbour
|
||||
next?: PageNeighbour
|
||||
}
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{
|
||||
href: 'https://x.com/simdotai',
|
||||
label: 'X (Twitter)',
|
||||
icon: 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/simstudioai/sim',
|
||||
label: 'GitHub',
|
||||
icon: 'M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z',
|
||||
},
|
||||
{
|
||||
href: 'https://discord.gg/Hr4UWYEcTT',
|
||||
label: 'Discord',
|
||||
icon: 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z',
|
||||
},
|
||||
] as const
|
||||
|
||||
export function PageFooter({ previous, next }: PageFooterProps) {
|
||||
return (
|
||||
<div className='mt-12'>
|
||||
<div className='h-px w-full bg-[rgba(0,0,0,0.08)] dark:bg-[rgba(255,255,255,0.08)]' />
|
||||
|
||||
{(previous || next) && (
|
||||
<div className='flex'>
|
||||
{previous ? (
|
||||
<Link
|
||||
href={previous.url}
|
||||
className='group flex flex-1 flex-col gap-1 px-6 py-5 transition-colors hover:bg-[rgba(0,0,0,0.02)] dark:hover:bg-[rgba(255,255,255,0.03)]'
|
||||
>
|
||||
<span className='text-[rgba(0,0,0,0.4)] text-xs dark:text-[rgba(255,255,255,0.35)]'>
|
||||
Previous
|
||||
</span>
|
||||
<span className='flex items-center gap-1.5 font-[470] text-[rgba(0,0,0,0.7)] text-sm transition-colors group-hover:text-[rgba(0,0,0,0.88)] dark:text-[rgba(255,255,255,0.7)] dark:group-hover:text-[rgba(255,255,255,0.92)]'>
|
||||
<ChevronLeft className='h-3.5 w-3.5 shrink-0' />
|
||||
{previous.name}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className='flex-1' />
|
||||
)}
|
||||
|
||||
{previous && next && (
|
||||
<div className='w-px bg-[rgba(0,0,0,0.08)] dark:bg-[rgba(255,255,255,0.08)]' />
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<Link
|
||||
href={next.url}
|
||||
className='group flex flex-1 flex-col items-end gap-1 px-6 py-5 transition-colors hover:bg-[rgba(0,0,0,0.02)] dark:hover:bg-[rgba(255,255,255,0.03)]'
|
||||
>
|
||||
<span className='text-[rgba(0,0,0,0.4)] text-xs dark:text-[rgba(255,255,255,0.35)]'>
|
||||
Next
|
||||
</span>
|
||||
<span className='flex items-center gap-1.5 font-[470] text-[rgba(0,0,0,0.7)] text-sm transition-colors group-hover:text-[rgba(0,0,0,0.88)] dark:text-[rgba(255,255,255,0.7)] dark:group-hover:text-[rgba(255,255,255,0.92)]'>
|
||||
{next.name}
|
||||
<ChevronRight className='h-3.5 w-3.5 shrink-0' />
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className='flex-1' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='h-px w-full bg-[rgba(0,0,0,0.08)] dark:bg-[rgba(255,255,255,0.08)]' />
|
||||
|
||||
<div className='flex items-center gap-4 pt-10 pb-6'>
|
||||
{SOCIAL_LINKS.map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label={link.label}
|
||||
>
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
className='h-5 w-5 fill-gray-400 transition-colors hover:fill-gray-500 dark:fill-gray-500 dark:hover:fill-gray-400'
|
||||
>
|
||||
<path d={link.icon} />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function PageNavigationArrows({ previous, next }: PageNavigationArrowsPro
|
||||
{previous && (
|
||||
<Link
|
||||
href={previous.url}
|
||||
className='inline-flex items-center justify-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-1.5 text-muted-foreground/60 text-sm transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
|
||||
className='inline-flex items-center justify-center rounded-[5px] border border-transparent px-2.5 py-2 text-foreground/40 transition-colors duration-200 hover:border-border/40 hover:bg-neutral-100 hover:text-foreground/70 dark:hover:bg-neutral-800'
|
||||
aria-label='Previous page'
|
||||
title='Previous page'
|
||||
>
|
||||
@@ -30,7 +30,7 @@ export function PageNavigationArrows({ previous, next }: PageNavigationArrowsPro
|
||||
{next && (
|
||||
<Link
|
||||
href={next.url}
|
||||
className='inline-flex items-center justify-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-1.5 text-muted-foreground/60 text-sm transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
|
||||
className='inline-flex items-center justify-center rounded-[5px] border border-transparent px-2.5 py-2 text-foreground/40 transition-colors duration-200 hover:border-border/40 hover:bg-neutral-100 hover:text-foreground/70 dark:hover:bg-neutral-800'
|
||||
aria-label='Next page'
|
||||
title='Next page'
|
||||
>
|
||||
|
||||
@@ -2,12 +2,36 @@
|
||||
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import type { Folder, Item, Separator } from 'fumadocs-core/page-tree'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { i18n } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const LANG_PREFIXES = ['/en', '/es', '/fr', '/de', '/ja', '/zh']
|
||||
function SidebarChevron({ open, className }: { open: boolean; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width='5'
|
||||
height='8'
|
||||
viewBox='0 0 6 10'
|
||||
fill='none'
|
||||
className={cn(
|
||||
'flex-shrink-0 transition-transform duration-200',
|
||||
open && 'rotate-90',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d='M1 1L5 5L1 9'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const LANG_PREFIXES = i18n.languages.map((l) => `/${l}`)
|
||||
|
||||
function stripLangPrefix(path: string): string {
|
||||
for (const prefix of LANG_PREFIXES) {
|
||||
@@ -26,6 +50,22 @@ function isActive(url: string, pathname: string, nested = true): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
const ITEM_BASE =
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors text-fd-muted-foreground hover:bg-fd-accent/50 hover:text-fd-accent-foreground'
|
||||
const ITEM_ACTIVE_MOBILE = 'bg-fd-primary/10 font-medium text-fd-primary'
|
||||
|
||||
const ITEM_DESKTOP =
|
||||
'lg:mb-[0.0625rem] lg:block lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:font-normal lg:text-[13px] lg:leading-tight'
|
||||
const ITEM_TEXT = 'lg:text-[#3b3b3b] lg:dark:text-[#cdcdcd]'
|
||||
const ITEM_HOVER = 'lg:hover:bg-[#f2f2f2] lg:dark:hover:bg-[#262626]'
|
||||
const ITEM_ACTIVE =
|
||||
'lg:bg-[#ececec] lg:font-normal lg:text-[#3b3b3b] lg:dark:bg-[#2c2c2c] lg:dark:text-[#cdcdcd]'
|
||||
|
||||
const FOLDER_TEXT = 'lg:text-[#3b3b3b] lg:font-medium lg:dark:text-[#cdcdcd]'
|
||||
const FOLDER_HOVER = 'lg:hover:bg-[#f2f2f2] lg:dark:hover:bg-[#262626]'
|
||||
const FOLDER_ACTIVE =
|
||||
'lg:bg-[#ececec] lg:text-[#3b3b3b] lg:dark:bg-[#2c2c2c] lg:dark:text-[#cdcdcd]'
|
||||
|
||||
export function SidebarItem({ item }: { item: Item }) {
|
||||
const pathname = usePathname()
|
||||
const active = isActive(item.url, pathname, false)
|
||||
@@ -35,16 +75,12 @@ export function SidebarItem({ item }: { item: Item }) {
|
||||
href={item.url}
|
||||
data-active={active}
|
||||
className={cn(
|
||||
// Mobile styles (default)
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
'text-fd-muted-foreground hover:bg-fd-accent/50 hover:text-fd-accent-foreground',
|
||||
active && 'bg-fd-primary/10 font-medium text-fd-primary',
|
||||
// Desktop styles (lg+)
|
||||
'lg:mb-[0.0625rem] lg:block lg:rounded-md lg:px-2.5 lg:py-1.5 lg:font-normal lg:text-[13px] lg:leading-tight',
|
||||
'lg:text-gray-600 lg:dark:text-gray-400',
|
||||
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
|
||||
active &&
|
||||
'lg:bg-emerald-50/80 lg:font-normal lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
|
||||
ITEM_BASE,
|
||||
active && ITEM_ACTIVE_MOBILE,
|
||||
ITEM_DESKTOP,
|
||||
ITEM_TEXT,
|
||||
!active && ITEM_HOVER,
|
||||
active && ITEM_ACTIVE
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
@@ -81,16 +117,12 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
|
||||
href={item.index.url}
|
||||
data-active={active}
|
||||
className={cn(
|
||||
// Mobile styles (default)
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
'text-fd-muted-foreground hover:bg-fd-accent/50 hover:text-fd-accent-foreground',
|
||||
active && 'bg-fd-primary/10 font-medium text-fd-primary',
|
||||
// Desktop styles (lg+)
|
||||
'lg:mb-[0.0625rem] lg:block lg:rounded-md lg:px-2.5 lg:py-1.5 lg:font-normal lg:text-[13px] lg:leading-tight',
|
||||
'lg:text-gray-600 lg:dark:text-gray-400',
|
||||
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
|
||||
active &&
|
||||
'lg:bg-emerald-50/80 lg:font-normal lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
|
||||
ITEM_BASE,
|
||||
active && ITEM_ACTIVE_MOBILE,
|
||||
ITEM_DESKTOP,
|
||||
ITEM_TEXT,
|
||||
!active && ITEM_HOVER,
|
||||
active && ITEM_ACTIVE
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
@@ -102,66 +134,48 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
|
||||
<div className='flex flex-col lg:mb-[0.0625rem]'>
|
||||
<div className='flex w-full items-center lg:gap-0.5'>
|
||||
{item.index ? (
|
||||
<Link
|
||||
href={item.index.url}
|
||||
data-active={active}
|
||||
className={cn(
|
||||
// Mobile styles (default)
|
||||
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
'text-fd-muted-foreground hover:bg-fd-accent/50 hover:text-fd-accent-foreground',
|
||||
active && 'bg-fd-primary/10 font-medium text-fd-primary',
|
||||
// Desktop styles (lg+)
|
||||
'lg:block lg:flex-1 lg:rounded-md lg:px-2.5 lg:py-1.5 lg:font-medium lg:text-[13px] lg:leading-tight',
|
||||
'lg:text-gray-800 lg:dark:text-gray-200',
|
||||
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
|
||||
active &&
|
||||
'lg:bg-emerald-50/80 lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
|
||||
<>
|
||||
<Link
|
||||
href={item.index.url}
|
||||
data-active={active}
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
'text-fd-muted-foreground hover:bg-fd-accent/50 hover:text-fd-accent-foreground',
|
||||
active && ITEM_ACTIVE_MOBILE,
|
||||
'lg:block lg:flex-1 lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:text-[13px] lg:leading-tight',
|
||||
FOLDER_TEXT,
|
||||
!active && FOLDER_HOVER,
|
||||
active && FOLDER_ACTIVE
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'rounded p-1 hover:bg-fd-accent/50',
|
||||
'lg:cursor-pointer lg:rounded lg:p-1 lg:transition-colors lg:hover:bg-[#f2f2f2] lg:dark:hover:bg-[#262626]'
|
||||
)}
|
||||
aria-label={open ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<SidebarChevron open={open} className='text-[#5e5e5e] dark:text-[#939393]' />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
// Mobile styles (default)
|
||||
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
'text-fd-muted-foreground hover:bg-fd-accent/50',
|
||||
// Desktop styles (lg+)
|
||||
'lg:flex lg:w-full lg:cursor-pointer lg:items-center lg:justify-between lg:rounded-md lg:px-2.5 lg:py-1.5 lg:text-left lg:font-medium lg:text-[13px] lg:leading-tight',
|
||||
'lg:text-gray-800 lg:hover:bg-gray-100/60 lg:dark:text-gray-200 lg:dark:hover:bg-gray-800/40'
|
||||
'lg:flex lg:w-full lg:cursor-pointer lg:items-center lg:justify-between lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:text-left lg:text-[13px] lg:leading-tight',
|
||||
FOLDER_TEXT,
|
||||
FOLDER_HOVER
|
||||
)}
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
{/* Desktop-only chevron for non-index folders */}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'ml-auto hidden h-3 w-3 flex-shrink-0 text-gray-400 transition-transform duration-200 ease-in-out lg:block dark:text-gray-500',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
// Mobile styles
|
||||
'rounded p-1 hover:bg-fd-accent/50',
|
||||
// Desktop styles
|
||||
'lg:cursor-pointer lg:rounded lg:p-1 lg:transition-colors lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40'
|
||||
)}
|
||||
aria-label={open ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
// Mobile styles
|
||||
'h-4 w-4 transition-transform',
|
||||
// Desktop styles
|
||||
'lg:h-3 lg:w-3 lg:text-gray-400 lg:duration-200 lg:ease-in-out lg:dark:text-gray-500',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<SidebarChevron open={open} className='ml-auto text-[#5e5e5e] dark:text-[#939393]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -173,10 +187,8 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
|
||||
)}
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
{/* Mobile: simple indent */}
|
||||
<div className='ml-4 flex flex-col gap-0.5 lg:hidden'>{children}</div>
|
||||
{/* Desktop: styled with border */}
|
||||
<ul className='mt-0.5 ml-2 hidden space-y-[0.0625rem] border-gray-200/60 border-l pl-2.5 lg:block dark:border-gray-700/60'>
|
||||
<ul className='mt-0.5 ml-2 hidden space-y-[0.0625rem] border-[#ececec] border-l pl-2.5 lg:block dark:border-[#2c2c2c]'>
|
||||
{children}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -188,16 +200,24 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
|
||||
|
||||
export function SidebarSeparator({ item }: { item: Separator }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
// Mobile styles
|
||||
'mt-4 mb-2 px-2 font-medium text-fd-muted-foreground text-xs',
|
||||
// Desktop styles
|
||||
'lg:mt-4 lg:mb-1.5 lg:px-2.5 lg:font-semibold lg:text-[10px] lg:text-gray-500/80 lg:uppercase lg:tracking-wide lg:dark:text-gray-500'
|
||||
)}
|
||||
<div
|
||||
data-separator
|
||||
className={cn('mt-5 mb-1.5 px-2', 'lg:relative lg:mt-0 lg:mb-1.5 lg:px-[13px] lg:pt-0')}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
<div className='separator-divider hidden'>
|
||||
<div className='h-[20px]' />
|
||||
<div className='h-px bg-[#ececec] dark:bg-[#2c2c2c]' />
|
||||
<div className='h-[20px]' />
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium text-fd-muted-foreground text-xs',
|
||||
'lg:font-semibold lg:text-[#5e5e5e] lg:text-[10px] lg:uppercase lg:tracking-[0.06em] lg:dark:text-[#939393]'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowRight, ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export function TOCFooter() {
|
||||
return (
|
||||
<div className='sticky bottom-0 mt-6'>
|
||||
<div className='flex flex-col gap-2 rounded-lg border border-border bg-secondary p-6 text-sm'>
|
||||
<div className='text-balance font-semibold text-base leading-tight'>
|
||||
Start building today
|
||||
</div>
|
||||
<div className='text-muted-foreground'>Trusted by over 100,000 builders.</div>
|
||||
<div className='text-muted-foreground'>
|
||||
The open-source platform to build AI agents and run your agentic workforce.
|
||||
</div>
|
||||
<Link
|
||||
href='https://sim.ai/signup'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group mt-2 inline-flex h-8 w-fit items-center justify-center gap-2 whitespace-nowrap rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-medium text-black text-sm outline-none transition-[filter] hover:brightness-110 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50'
|
||||
aria-label='Get started with Sim - Sign up for free'
|
||||
>
|
||||
<span>Get started</span>
|
||||
<span className='relative inline-flex h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
<ChevronRight
|
||||
className='absolute inset-0 h-4 w-4 transition-opacity duration-200 group-hover:opacity-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<ArrowRight
|
||||
className='absolute inset-0 h-4 w-4 opacity-0 transition-opacity duration-200 group-hover:opacity-100'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -124,6 +124,29 @@ export function ConditionalIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function CredentialIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<circle cx='8' cy='15' r='4' stroke='currentColor' strokeWidth='1.75' />
|
||||
<path d='M11.83 13.17L20 5' stroke='currentColor' strokeWidth='1.75' strokeLinecap='round' />
|
||||
<path
|
||||
d='M18 7l2 2'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.75'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M15 10l2 2'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.75'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -2109,7 +2132,15 @@ export function Mem0Icon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function ExtendIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 33 18' fill='none'>
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 33 24' fill='none'>
|
||||
<path
|
||||
d='M6.3782 13.7746L4.28874 14.8056C4.11382 14.8899 4.11382 15.1367 4.28874 15.2211L15.8924 20.9462C16.1423 21.068 16.433 21.068 16.6797 20.9462L28.2864 15.2211C28.4582 15.1367 28.4582 14.8899 28.2864 14.8056L26.2 13.7746C27.3838 13.1937 28.5145 12.6378 29.4578 12.1787C30.2605 12.5722 31.0666 12.9689 31.8693 13.3625C32.3003 13.5749 32.5721 14.0123 32.5721 14.4932V15.5426C32.5721 16.0204 32.3003 16.4609 31.8693 16.6733C31.8693 16.6733 19.5816 22.7016 17.5542 23.6887C16.7296 24.0916 15.8955 24.1103 15.0615 23.7043C12.8123 22.6078 1.9646 17.2857 0.705842 16.6672C0.274806 16.4579 0 16.0174 0 15.5395V14.4899C4.1552e-05 14.012 0.271779 13.5715 0.702792 13.3591C1.43993 12.9968 2.2584 12.5973 3.12047 12.1756C4.06685 12.641 5.19446 13.1937 6.3782 13.7746Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M15.1021 6.30652C15.9017 5.92234 16.717 5.9348 17.5103 6.32207C20.1715 7.62145 22.8297 8.92398 25.4878 10.2265L22.249 11.8257L16.6797 9.07681C16.433 8.955 16.1423 8.955 15.8924 9.07681L10.3262 11.8257L7.0874 10.2265C11.2142 8.20664 15.0743 6.3201 15.1021 6.30652Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
clipRule='evenodd'
|
||||
d='M16.2893 0C16.6984 1.91708e-05 17.1074 0.0970011 17.5103 0.293745C22.3018 2.63326 27.0841 4.98521 31.8693 7.33722C32.3003 7.54649 32.5721 7.9868 32.5721 8.46461V9.51422C32.5721 9.99522 32.3004 10.4357 31.8693 10.645C31.8693 10.645 19.5816 16.6732 17.5542 17.6634C17.1357 17.8696 16.692 17.9727 16.2859 17.9727C15.8799 17.9727 15.4707 17.8758 15.0615 17.6759C12.8124 16.5795 1.9646 11.2604 0.705842 10.6419C0.274826 10.4295 2.31482e-05 9.99216 0 9.51117V8.46461C4.59913e-05 7.98366 0.271816 7.54656 0.702792 7.33417C5.8977 4.7819 15.0599 0.301869 15.1021 0.281239C15.4957 0.0938275 15.8801 0 16.2893 0ZM16.2859 2.96124C16.1516 2.96126 16.0173 2.98909 15.8924 3.05153L4.28874 8.77696C4.11382 8.86442 4.11382 9.10831 4.28874 9.19577L15.8924 14.9209C16.0173 14.9802 16.1516 15.0115 16.2859 15.0115C16.4202 15.0115 16.5548 14.9802 16.6797 14.9209L28.2864 9.19577C28.4582 9.10831 28.4582 8.86442 28.2864 8.77696L16.6797 3.05153C16.5548 2.98906 16.4202 2.96124 16.2859 2.96124Z'
|
||||
@@ -4630,6 +4661,59 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function CloudFormationIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 80 80'
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
||||
>
|
||||
<g
|
||||
id='Icon-Architecture/64/Arch_AWS-CloudFormation_64'
|
||||
stroke='none'
|
||||
strokeWidth='1'
|
||||
fill='none'
|
||||
fillRule='evenodd'
|
||||
>
|
||||
<path
|
||||
d='M53,39.9632039 L58,39.9632039 L58,37.9601375 L53,37.9601375 L53,39.9632039 Z M28,51.9816019 L33,51.9816019 L33,49.9785356 L28,49.9785356 L28,51.9816019 Z M18,51.9816019 L25,51.9816019 L25,49.9785356 L18,49.9785356 L18,51.9816019 Z M18,45.9724029 L30,45.9724029 L30,43.9693366 L18,43.9693366 L18,45.9724029 Z M18,33.9540048 L27,33.9540048 L27,31.9509385 L18,31.9509385 L18,33.9540048 Z M18,39.9632039 L51,39.9632039 L51,37.9601375 L18,37.9601375 L18,39.9632039 Z M37,61.9969337 L14,61.9969337 L14,27.9448058 L37,27.9448058 L37,35.9570712 L39,35.9570712 L39,26.9432726 C39,26.3904263 38.552,25.9417395 38,25.9417395 L13,25.9417395 C12.447,25.9417395 12,26.3904263 12,26.9432726 L12,62.9984668 C12,63.5513131 12.447,64 13,64 L38,64 C38.552,64 39,63.5513131 39,62.9984668 L39,42.9678034 L37,42.9678034 L37,61.9969337 Z M68,36.9586044 C68,43.4305117 62.173,45.6819583 59.092,45.9683968 L43,45.9724029 L43,43.9693366 L59,43.9693366 C59.195,43.9463013 66,43.2121775 66,36.9586044 C66,31.2638867 60.863,30.1081175 59.834,29.9338507 C59.321,29.8467173 58.96,29.3820059 59.004,28.8632117 C59.005,28.8441826 59.007,28.826155 59.009,28.8081274 C58.954,25.5902013 56.981,24.584662 56.126,24.3002266 C54.53,23.769414 52.751,24.2771913 51.81,25.5391231 C51.591,25.8355769 51.229,25.9868085 50.861,25.9307226 C50.497,25.8756383 50.192,25.625255 50.068,25.2767214 C49.447,23.5360568 48.546,22.4083304 47.293,21.1534094 C44.159,18.0386412 39.905,17.1783242 35.925,18.8528877 C33.837,19.7332353 32.012,21.7282894 30.922,24.327268 L29.078,23.5500782 C30.37,20.4743699 32.584,18.0887179 35.15,17.007062 C39.905,15.0049972 44.971,16.0255595 48.704,19.7342369 C49.774,20.8068789 50.66,21.851478 51.35,23.2035478 C52.843,22.0978551 54.857,21.7673492 56.757,22.3993166 C59.189,23.2085554 60.727,25.3207889 60.975,28.1290879 C64.381,28.9884034 68,31.7115721 68,36.9586044 L68,36.9586044 Z'
|
||||
id='AWS-CloudFormation_Icon_64_Squid'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CloudWatchIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 80 80'
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
||||
>
|
||||
<g
|
||||
id='Icon-Architecture/64/Arch_Amazon-CloudWatch_64'
|
||||
stroke='none'
|
||||
strokeWidth='1'
|
||||
fill='none'
|
||||
fillRule='evenodd'
|
||||
transform='translate(40, 40) scale(1.25) translate(-40, -40)'
|
||||
>
|
||||
<path
|
||||
d='M55.0592315,46.7773707 C55.0592315,42.8680281 51.8575588,39.6876305 47.9220646,39.6876305 C43.9865705,39.6876305 40.785903,42.8680281 40.785903,46.7773707 C40.785903,50.6867133 43.9865705,53.8671108 47.9220646,53.8671108 C51.8575588,53.8671108 55.0592315,50.6867133 55.0592315,46.7773707 M57.0697011,46.7773707 C57.0697011,51.7881194 52.9663327,55.8642207 47.9220646,55.8642207 C42.8788018,55.8642207 38.7754334,51.7881194 38.7754334,46.7773707 C38.7754334,41.7666219 42.8788018,37.6905206 47.9220646,37.6905206 C52.9663327,37.6905206 57.0697011,41.7666219 57.0697011,46.7773707 M65.5096522,60.4735504 L58.5011554,54.2026253 C57.9352082,54.9944794 57.2808004,55.7174332 56.5540156,56.3634982 L63.5524601,62.6334248 C64.1495696,63.1686502 65.0784065,63.1187225 65.6182176,62.5255808 C66.155013,61.9324392 66.1067617,61.010773 65.5096522,60.4735504 M47.9220646,57.6616197 C53.9645309,57.6616197 58.8801289,52.7786859 58.8801289,46.7773707 C58.8801289,40.7750569 53.9645309,35.8931217 47.9220646,35.8931217 C41.8806036,35.8931217 36.9650056,40.7750569 36.9650056,46.7773707 C36.9650056,52.7786859 41.8806036,57.6616197 47.9220646,57.6616197 M67.1119965,63.8626459 C66.4264264,64.6165549 65.47849,65 64.5285431,65 C63.7002296,65 62.8699057,64.708422 62.207456,64.1172774 L54.9305615,57.5987107 C52.9070239,58.8968321 50.505518,59.6587296 47.9220646,59.6587296 C40.7718297,59.6587296 34.9545361,53.8800921 34.9545361,46.7773707 C34.9545361,39.6746493 40.7718297,33.8960118 47.9220646,33.8960118 C55.0733048,33.8960118 60.8905985,39.6746493 60.8905985,46.7773707 C60.8905985,48.8154213 60.3990387,50.7366411 59.5465996,52.4511599 L66.8556616,58.9906963 C68.2750531,60.265851 68.3896499,62.4496907 67.1119965,63.8626459 M21.2803274,29.392529 C21.2803274,29.9117776 21.3124949,30.429029 21.3738143,30.9293051 C21.4089975,31.2138932 21.3205368,31.4984814 21.1295422,31.7131707 C20.9777518,31.8839236 20.7736891,31.9967603 20.550527,32.0347054 C18.0786547,32.6687878 14.0104695,34.5880104 14.0104695,40.3456782 C14.0104695,44.6933865 16.4240382,47.0929141 18.4495863,48.3411077 C19.1411878,48.7744806 19.9594489,49.0051468 20.8229456,49.0141338 L32.9450717,49.0251179 L32.9430613,51.0222278 L20.811888,51.0112437 C19.5664021,50.9982625 18.384246,50.6607509 17.3840374,50.0346569 C15.3765836,48.7974474 12,45.8896553 12,40.3456782 C12,33.66235 16.5999543,31.191925 19.3000149,30.319188 C19.2799102,30.0116331 19.2698579,29.702081 19.2698579,29.392529 C19.2698579,23.9324305 22.9982737,18.2696254 27.9420183,16.2215892 C33.7241287,13.8150717 39.8500294,15.0083449 44.3263399,19.4109737 C45.7135638,20.7749998 46.8545053,22.4316024 47.7300648,24.3478294 C48.9061895,23.3802296 50.355738,22.8460027 51.8836949,22.8460027 C54.8863312,22.8460027 58.2659305,25.1097268 58.8680661,30.0605622 C61.6797078,30.7046302 67.6206453,32.9553731 67.6206453,40.422567 C67.6206453,43.4042521 66.6797455,45.8666886 64.8230769,47.7419748 L63.3896121,46.3410022 C64.8632863,44.8531553 65.6101757,42.8620367 65.6101757,40.422567 C65.6101757,33.891019 60.1055101,32.2663701 57.737177,31.8719409 C57.4677741,31.827006 57.2295334,31.6752256 57.0757325,31.4515493 C56.9259525,31.2358614 56.8686541,30.9712444 56.9138897,30.7146157 C56.5851779,26.6604826 54.1605516,24.8431126 51.8836949,24.8431126 C50.4472144,24.8431126 49.1001998,25.5381069 48.1874466,26.7503526 C47.9652897,27.0439277 47.6044105,27.193711 47.2344841,27.139789 C46.8695838,27.085867 46.5629872,26.8362283 46.4373329,26.4917268 C45.6140456,24.2260057 44.4278686,22.3207628 42.9119745,20.8309188 C39.0327735,17.0154404 33.7281496,15.9809374 28.7170543,18.0649216 C24.5463352,19.7924217 21.2803274,24.7672224 21.2803274,29.392529'
|
||||
id='Amazon-CloudWatch_Icon_64_Squid'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -4845,6 +4929,49 @@ export function SSHIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function DagsterIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='21 21 518 518' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='M221.556 440.815C221.562 442.771 221.97 444.704 222.757 446.494C223.543 448.285 224.689 449.894 226.125 451.221C227.56 452.548 229.254 453.565 231.1 454.208C232.946 454.851 234.905 455.107 236.854 454.959C310.941 449.655 380.913 397.224 403.252 315.332C404.426 310.622 407.96 308.26 412.669 308.26C415.082 308.357 417.36 309.402 419.009 311.168C420.658 312.933 421.545 315.278 421.477 317.694C421.477 335.953 398.006 383.674 364.442 411.368C362.731 412.807 361.367 414.614 360.452 416.654C359.536 418.694 359.092 420.914 359.154 423.149C359.188 424.967 359.58 426.76 360.308 428.425C361.036 430.091 362.086 431.596 363.397 432.855C364.708 434.114 366.254 435.101 367.948 435.761C369.641 436.421 371.448 436.739 373.264 436.699C376.205 436.699 380.913 434.931 386.795 429.627C410.266 408.412 455 348.909 455 283.508C455 187.624 380.872 105 277.418 105C185.106 105 105.138 180.414 105.138 267.611C105.138 325.345 151.004 368.937 211.56 368.937C258.019 368.937 300.945 335.953 312.708 290.58C313.881 285.87 317.402 283.508 322.11 283.508C324.525 283.606 326.804 284.65 328.455 286.415C330.106 288.181 330.996 290.525 330.933 292.942C330.933 313.564 292.122 385.484 213.327 385.484C194.509 385.484 170.996 380.18 154.524 370.746C152.319 369.677 149.917 369.075 147.469 368.978C145.594 368.906 143.725 369.223 141.979 369.909C140.232 370.594 138.647 371.634 137.321 372.962C135.996 374.291 134.96 375.879 134.278 377.627C133.596 379.376 133.283 381.247 133.359 383.122C133.435 385.524 134.123 387.867 135.357 389.929C136.592 391.991 138.332 393.703 140.414 394.904C162.173 407.334 188.047 413.757 214.501 413.757C280.359 413.757 340.335 368.978 357.98 302.997C359.154 298.287 362.688 295.926 367.383 295.926C369.797 296.023 372.077 297.067 373.728 298.832C375.379 300.598 376.269 302.943 376.205 305.359C376.205 332.459 327.992 419.655 235.087 426.727C231.492 426.994 228.123 428.579 225.625 431.18C223.128 433.78 221.679 437.211 221.556 440.815V440.815Z'
|
||||
fill='#4F43DD'
|
||||
/>
|
||||
<path
|
||||
d='M313.62 215.178C326.301 215.083 338.748 218.589 349.517 225.288C350.605 219.33 351.206 213.292 351.312 207.236C351.312 179.266 329.995 154.211 304.038 154.211C283.853 154.211 271.233 170.937 271.233 191.6C271.137 202.763 275.057 213.588 282.279 222.098C292.062 217.431 302.782 215.064 313.62 215.178V215.178Z'
|
||||
fill='white'
|
||||
/>
|
||||
<path
|
||||
d='M374.439 316.505C378.042 304.185 379.63 295.635 379.63 290.083C379.52 287.685 378.493 285.421 376.761 283.76C375.028 282.099 372.724 281.168 370.325 281.16C368.089 281.202 365.932 281.99 364.196 283.399C362.46 284.808 361.244 286.757 360.743 288.936C359.762 292.983 357.664 303.95 355.593 310.912C356.449 308.306 357.231 305.658 357.94 302.97C359.114 298.246 362.648 295.898 367.342 295.898C369.756 295.991 372.035 297.033 373.687 298.796C375.338 300.559 376.228 302.902 376.165 305.318C376.054 309.115 375.446 312.881 374.356 316.519L374.439 316.505Z'
|
||||
fill='#352D8E'
|
||||
/>
|
||||
<path
|
||||
d='M424.418 303.632C424.305 301.237 423.278 298.977 421.55 297.317C419.821 295.658 417.522 294.724 415.126 294.709C412.893 294.754 410.739 295.543 409.006 296.952C407.272 298.36 406.059 300.308 405.558 302.485C404.564 306.629 402.424 317.761 400.325 324.709H400.422C401.444 321.615 402.396 318.48 403.183 315.289C404.357 310.565 407.891 308.217 412.599 308.217C415.012 308.311 417.29 309.353 418.939 311.116C420.588 312.88 421.475 315.223 421.408 317.637C421.341 320.569 420.938 323.485 420.207 326.325C423.134 316.049 424.418 308.618 424.418 303.632Z'
|
||||
fill='#352D8E'
|
||||
/>
|
||||
<path
|
||||
d='M313.619 215.178C319.921 215.166 326.196 216.007 332.272 217.678C335.462 213.326 337.056 208.008 336.786 202.618C336.516 197.228 334.398 192.095 330.789 188.084C327.18 184.073 322.3 181.428 316.97 180.594C311.64 179.761 306.185 180.789 301.524 183.507L311.189 199.419L293.089 191.587C290.637 195.545 289.407 200.139 289.555 204.793C289.702 209.446 291.22 213.953 293.917 217.747C300.34 216.016 306.967 215.152 313.619 215.178V215.178Z'
|
||||
fill='#030615'
|
||||
/>
|
||||
<path
|
||||
d='M174.172 317.583C181.797 317.583 187.979 311.399 187.979 303.771C187.979 296.143 181.797 289.959 174.172 289.959C166.547 289.959 160.365 296.143 160.365 303.771C160.365 311.399 166.547 317.583 174.172 317.583Z'
|
||||
fill='#352D8E'
|
||||
/>
|
||||
<path
|
||||
d='M174.172 262.335C181.797 262.335 187.979 256.151 187.979 248.523C187.979 240.895 181.797 234.711 174.172 234.711C166.547 234.711 160.365 240.895 160.365 248.523C160.365 256.151 166.547 262.335 174.172 262.335Z'
|
||||
fill='#352D8E'
|
||||
/>
|
||||
<path
|
||||
d='M146.558 289.958C154.183 289.958 160.364 283.774 160.364 276.146C160.364 268.518 154.183 262.334 146.558 262.334C138.932 262.334 132.751 268.518 132.751 276.146C132.751 283.774 138.932 289.958 146.558 289.958Z'
|
||||
fill='#352D8E'
|
||||
/>
|
||||
<path
|
||||
d='M208.688 368.91H211.45C257.909 368.91 300.835 335.927 312.598 290.554C313.771 285.844 317.292 283.482 322 283.482C324.415 283.579 326.694 284.624 328.345 286.389C329.996 288.155 330.886 290.499 330.823 292.916C330.612 297.737 329.522 302.479 327.606 306.908C327.939 306.393 328.23 305.853 328.476 305.292C331.969 297.304 333.774 288.679 333.777 279.96C333.777 266.41 324.361 257.571 310.844 257.571C287.276 257.571 282.554 278.151 272.614 300.154C262.3 322.999 243.357 347.709 195.586 347.709C145.951 347.709 94.9487 312.944 107.389 242.253C107.54 241.369 107.665 240.582 107.761 239.85C105.939 248.982 105.014 258.272 105 267.584C105.138 324.491 149.582 367.585 208.688 368.91Z'
|
||||
fill='#352D8E'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DatabricksIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 241 266' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
@@ -5854,6 +5981,33 @@ export function PulseIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SixtyfourIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 158 143' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='M32.3952 141.17L31.637 140.73V142.481L31.8417 142.603L32.3952 142.921L32.9487 142.603L33.1534 142.481V140.73L32.3952 141.17Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M33.1534 140.73V142.603H31.637V140.73L32.3952 141.17L33.1534 140.73Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M93.3271 105.608V106.564L94.0854 106.996L94.8436 106.564V105.608H93.3271Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M94.8436 105.608V106.564L94.0854 106.996L93.3271 106.564V105.608H94.8436Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M125.681 12.9895L94.836 30.755L63.9909 12.9895L32.3951 31.1872H32.3875V68.8565L0.79933 87.0542H0.791748V124.723L31.6369 142.481V140.73L2.30822 123.844V89.6701L31.6369 106.564V140.73L32.3951 141.17L33.1533 140.73V106.564L62.482 89.6701V123.844L33.1533 140.73V142.481L63.2402 125.163L93.3271 142.481L93.5318 142.603L94.0853 142.921L94.6388 142.603L94.8436 142.481L125.689 124.723V87.0542L126.235 86.7357L126.439 86.6144L157.284 68.8565V31.1872L125.681 12.9895ZM63.2326 84.8629L33.904 67.9769V33.8031L63.2326 50.6967V84.8629ZM64.7491 50.6967L94.0777 33.8031V67.9769L64.7491 84.8629V50.6967ZM124.172 123.844L94.8436 140.73V106.564L94.0853 106.996L93.3271 106.564V140.73L63.9985 123.844V89.6701L93.3271 106.564V105.608H94.8436V106.564L124.172 89.6701V123.844ZM124.923 84.8629L95.5942 67.9769V33.8031L124.923 50.6891V84.8629ZM155.768 67.9769L126.439 84.8629V50.6967L155.768 33.8031V67.9769Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimilarwebIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -21,7 +21,6 @@ const NAV_TABS = [
|
||||
match: (p: string) => p.includes('/api-reference'),
|
||||
external: false,
|
||||
},
|
||||
{ label: 'Mothership', href: 'https://sim.ai', external: true },
|
||||
] as const
|
||||
|
||||
export function Navbar() {
|
||||
@@ -34,12 +33,12 @@ export function Navbar() {
|
||||
<div
|
||||
className='relative flex h-[52px] w-full items-center justify-between'
|
||||
style={{
|
||||
paddingLeft: 'calc(var(--sidebar-offset) + 32px)',
|
||||
paddingRight: 'calc(var(--toc-offset) + 60px)',
|
||||
paddingLeft: 'calc(var(--sidebar-offset) + var(--nav-inset))',
|
||||
paddingRight: 'calc(var(--toc-offset) + var(--nav-inset))',
|
||||
}}
|
||||
>
|
||||
<Link href='/' className='flex min-w-[100px] items-center'>
|
||||
<SimLogoFull className='h-7 w-auto' />
|
||||
<SimLogoFull className='h-[20px] w-auto' />
|
||||
</Link>
|
||||
|
||||
<div className='-translate-x-1/2 absolute left-1/2 flex items-center justify-center'>
|
||||
@@ -49,24 +48,20 @@ export function Navbar() {
|
||||
<div className='flex items-center gap-1'>
|
||||
<LanguageDropdown />
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href='https://sim.ai'
|
||||
className='ml-1 flex items-center rounded-[8px] bg-[#33c482] px-2.5 py-1.5 text-[13px] text-white transition-colors duration-200 hover:bg-[#2DAC72]'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider — only spans content width */}
|
||||
<div
|
||||
className='border-b'
|
||||
style={{
|
||||
marginLeft: 'calc(var(--sidebar-offset) + 32px)',
|
||||
marginRight: 'calc(var(--toc-offset) + 60px)',
|
||||
borderColor: 'rgba(128, 128, 128, 0.1)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom row: navigation tabs — border on row, tabs overlap it */}
|
||||
<div
|
||||
className='flex h-[40px] items-stretch gap-6 border-border/20 border-b'
|
||||
style={{
|
||||
paddingLeft: 'calc(var(--sidebar-offset) + 32px)',
|
||||
paddingLeft: 'calc(var(--sidebar-offset) + var(--nav-inset))',
|
||||
}}
|
||||
>
|
||||
{NAV_TABS.map((tab) => {
|
||||
@@ -79,12 +74,12 @@ export function Navbar() {
|
||||
className={cn(
|
||||
'-mb-px relative flex items-center border-b text-[14px] tracking-[-0.01em] transition-colors',
|
||||
isActive
|
||||
? 'border-neutral-400 font-[550] text-neutral-800 dark:border-neutral-500 dark:text-neutral-200'
|
||||
: 'border-transparent font-medium text-fd-muted-foreground hover:border-neutral-300 hover:text-neutral-600 dark:hover:border-neutral-600 dark:hover:text-neutral-400'
|
||||
? 'border-neutral-400 font-[480] text-neutral-800 dark:border-neutral-500 dark:text-neutral-200'
|
||||
: 'border-transparent font-[430] text-neutral-500 hover:border-neutral-300 hover:text-neutral-600 dark:text-neutral-400 dark:hover:border-neutral-600 dark:hover:text-neutral-300'
|
||||
)}
|
||||
>
|
||||
{/* Invisible bold text reserves width to prevent layout shift */}
|
||||
<span className='invisible font-[550]'>{tab.label}</span>
|
||||
<span className='invisible font-[480]'>{tab.label}</span>
|
||||
<span className='absolute'>{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ export function LLMCopyButton({ content }: { content: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className='flex cursor-pointer items-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-2 text-muted-foreground/60 text-sm leading-none transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
|
||||
className='flex cursor-pointer items-center gap-1.5 rounded-[5px] border border-transparent px-2.5 py-2 text-[13px] text-foreground/40 leading-none transition-colors duration-200 hover:border-border/40 hover:bg-neutral-100 hover:text-foreground/70 dark:hover:bg-neutral-800'
|
||||
aria-label={checked ? 'Copied to clipboard' : 'Copy page content'}
|
||||
>
|
||||
{checked ? (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import Script from 'next/script'
|
||||
|
||||
interface StructuredDataProps {
|
||||
title: string
|
||||
description: string
|
||||
@@ -68,29 +66,6 @@ export function StructuredData({
|
||||
})),
|
||||
}
|
||||
|
||||
const websiteStructuredData = url === baseUrl && {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Sim Documentation',
|
||||
url: baseUrl,
|
||||
description:
|
||||
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'Sim',
|
||||
url: baseUrl,
|
||||
},
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: `${baseUrl}/search?q={search_term_string}`,
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
inLanguage: ['en', 'es', 'fr', 'de', 'ja', 'zh'],
|
||||
}
|
||||
|
||||
const softwareStructuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
@@ -121,34 +96,22 @@ export function StructuredData({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
id='article-structured-data'
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(articleStructuredData),
|
||||
}}
|
||||
/>
|
||||
{breadcrumbStructuredData && (
|
||||
<Script
|
||||
id='breadcrumb-structured-data'
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(breadcrumbStructuredData),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{websiteStructuredData && (
|
||||
<Script
|
||||
id='website-structured-data'
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(websiteStructuredData),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{url === baseUrl && (
|
||||
<Script
|
||||
id='software-structured-data'
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(softwareStructuredData),
|
||||
|
||||
@@ -7,34 +7,25 @@ interface BlockInfoCardProps {
|
||||
type: string
|
||||
color: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
iconSvg?: string // Deprecated: Use automatic icon resolution instead
|
||||
}
|
||||
|
||||
export function BlockInfoCard({
|
||||
type,
|
||||
color,
|
||||
icon: IconComponent,
|
||||
iconSvg,
|
||||
}: BlockInfoCardProps): React.ReactNode {
|
||||
// Auto-resolve icon component from block type if not explicitly provided
|
||||
const ResolvedIcon = IconComponent || blockTypeToIconMap[type] || null
|
||||
|
||||
return (
|
||||
<div className='mb-6 overflow-hidden rounded-lg border border-border'>
|
||||
<div className='flex items-center justify-center p-6'>
|
||||
<div
|
||||
className='flex h-20 w-20 items-center justify-center rounded-lg'
|
||||
style={{ background: color }}
|
||||
>
|
||||
{ResolvedIcon ? (
|
||||
<ResolvedIcon className='h-10 w-10 text-white' />
|
||||
) : iconSvg ? (
|
||||
<div className='h-10 w-10 text-white' dangerouslySetInnerHTML={{ __html: iconSvg }} />
|
||||
) : (
|
||||
<div className='font-mono text-xl opacity-70'>{type.substring(0, 2)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='mb-6 flex items-center justify-center overflow-hidden rounded-lg p-8'
|
||||
style={{ background: color }}
|
||||
>
|
||||
{ResolvedIcon ? (
|
||||
<ResolvedIcon className='h-10 w-10 text-white' />
|
||||
) : (
|
||||
<div className='font-mono text-white text-xl opacity-70'>{type.substring(0, 2)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const variants = {
|
||||
} as const
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
|
||||
'inline-flex items-center justify-center rounded-[5px] p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
|
||||
{
|
||||
variants: {
|
||||
variant: variants,
|
||||
|
||||
@@ -17,6 +17,7 @@ export function CodeBlock(props: React.ComponentProps<typeof FumadocsCodeBlock>)
|
||||
return (
|
||||
<FumadocsCodeBlock
|
||||
{...props}
|
||||
className={cn('!border !border-fd-border !shadow-none', props.className)}
|
||||
Actions={({ className }) => (
|
||||
<div className={cn('empty:hidden', className)}>
|
||||
<button
|
||||
|
||||
@@ -5,19 +5,23 @@ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ANIMATION_CLASSES =
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
ANIMATION_CLASSES,
|
||||
'z-50 max-h-[240px] min-w-[8rem] max-w-[220px] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-lg border border-neutral-200 bg-white p-1.5 shadow-sm dark:border-neutral-800 dark:bg-neutral-900',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -33,7 +37,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 font-medium text-[13px] text-neutral-700 outline-none transition-colors focus:bg-neutral-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:text-neutral-300 dark:focus:bg-neutral-800',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -48,7 +52,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex cursor-default select-none items-center rounded-[5px] py-1.5 pr-2 pl-7 font-medium text-[13px] text-neutral-700 outline-none transition-colors focus:bg-neutral-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:text-neutral-300 dark:focus:bg-neutral-800',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -56,7 +60,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className='h-4 w-4' />
|
||||
<Check className='h-3.5 w-3.5' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
|
||||
@@ -13,32 +13,85 @@ interface FAQProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
function FAQItemRow({
|
||||
item,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
item: FAQItem
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
className='flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left font-[470] text-[0.875rem] text-[rgba(0,0,0,0.8)] transition-colors hover:bg-[rgba(0,0,0,0.02)] dark:text-[rgba(255,255,255,0.85)] dark:hover:bg-[rgba(255,255,255,0.03)]'
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 shrink-0 text-[rgba(0,0,0,0.3)] transition-transform duration-200 dark:text-[rgba(255,255,255,0.3)] ${
|
||||
isOpen ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
{item.question}
|
||||
</button>
|
||||
<div
|
||||
className='grid transition-[grid-template-rows,opacity] duration-200 ease-in-out'
|
||||
style={{
|
||||
gridTemplateRows: isOpen ? '1fr' : '0fr',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='px-4 pt-2 pb-2.5 pl-11 text-[0.875rem] text-[rgba(0,0,0,0.7)] leading-relaxed dark:text-[rgba(255,255,255,0.7)]'>
|
||||
{item.answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FAQ({ items, title = 'Common Questions' }: FAQProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null)
|
||||
|
||||
const faqSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: items.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-12'>
|
||||
<h2 className='mb-4 font-bold text-xl'>{title}</h2>
|
||||
<div className='rounded-xl border border-border'>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
/>
|
||||
<h2 className='mb-4 font-[500] text-xl'>{title}</h2>
|
||||
<div className='border-[rgba(0,0,0,0.08)] border-t border-b dark:border-[rgba(255,255,255,0.08)]'>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className={index !== items.length - 1 ? 'border-border border-b' : ''}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpenIndex(openIndex === index ? null : index)}
|
||||
className='flex w-full cursor-pointer items-center gap-3 px-5 py-4 text-left font-medium text-[0.9375rem]'
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 shrink-0 text-fd-muted-foreground transition-transform duration-200 ${
|
||||
openIndex === index ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
{item.question}
|
||||
</button>
|
||||
{openIndex === index && (
|
||||
<div className='px-5 pb-4 pl-12 text-[0.9375rem] text-fd-muted-foreground leading-relaxed'>
|
||||
{item.answer}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={index}
|
||||
className={
|
||||
index !== items.length - 1
|
||||
? 'border-[rgba(0,0,0,0.08)] border-b dark:border-[rgba(255,255,255,0.08)]'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<FAQItemRow
|
||||
item={item}
|
||||
isOpen={openIndex === index}
|
||||
onToggle={() => setOpenIndex(openIndex === index ? null : index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Heading({ as, className, ...props }: HeadingProps) {
|
||||
|
||||
return (
|
||||
<As className={cn('group flex scroll-m-28 flex-row items-center gap-2', className)} {...props}>
|
||||
<a data-card='' href={`#${props.id}`} className='peer' onClick={handleClick}>
|
||||
<a href={`#${props.id}`} className='peer' onClick={handleClick}>
|
||||
{props.children}
|
||||
</a>
|
||||
{copied ? (
|
||||
|
||||
@@ -27,9 +27,12 @@ import {
|
||||
CirclebackIcon,
|
||||
ClayIcon,
|
||||
ClerkIcon,
|
||||
CloudFormationIcon,
|
||||
CloudflareIcon,
|
||||
CloudWatchIcon,
|
||||
ConfluenceIcon,
|
||||
CursorIcon,
|
||||
DagsterIcon,
|
||||
DatabricksIcon,
|
||||
DatadogIcon,
|
||||
DevinIcon,
|
||||
@@ -152,6 +155,7 @@ import {
|
||||
SftpIcon,
|
||||
ShopifyIcon,
|
||||
SimilarwebIcon,
|
||||
SixtyfourIcon,
|
||||
SlackIcon,
|
||||
SmtpIcon,
|
||||
SQSIcon,
|
||||
@@ -211,8 +215,11 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
clay: ClayIcon,
|
||||
clerk: ClerkIcon,
|
||||
cloudflare: CloudflareIcon,
|
||||
cloudformation: CloudFormationIcon,
|
||||
cloudwatch: CloudWatchIcon,
|
||||
confluence_v2: ConfluenceIcon,
|
||||
cursor_v2: CursorIcon,
|
||||
dagster: DagsterIcon,
|
||||
databricks: DatabricksIcon,
|
||||
datadog: DatadogIcon,
|
||||
devin: DevinIcon,
|
||||
@@ -279,7 +286,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
langsmith: LangsmithIcon,
|
||||
launchdarkly: LaunchDarklyIcon,
|
||||
lemlist: LemlistIcon,
|
||||
linear: LinearIcon,
|
||||
linear_v2: LinearIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
linkup: LinkupIcon,
|
||||
loops: LoopsIcon,
|
||||
@@ -336,6 +343,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
shopify: ShopifyIcon,
|
||||
similarweb: SimilarwebIcon,
|
||||
sixtyfour: SixtyfourIcon,
|
||||
slack: SlackIcon,
|
||||
smtp: SmtpIcon,
|
||||
sqs: SQSIcon,
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Image({
|
||||
<>
|
||||
<NextImage
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-border object-cover shadow-sm',
|
||||
'overflow-hidden rounded-xl border border-border object-cover',
|
||||
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-95',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { Check } from 'lucide-react'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const languages = {
|
||||
@@ -15,8 +21,6 @@ const languages = {
|
||||
}
|
||||
|
||||
export function LanguageDropdown() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number>(-1)
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
@@ -38,15 +42,10 @@ export function LanguageDropdown() {
|
||||
setCurrentLang('en')
|
||||
}
|
||||
}
|
||||
}, [params, currentLang])
|
||||
}, [params])
|
||||
|
||||
const handleLanguageChange = (locale: string) => {
|
||||
if (locale === currentLang) {
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsOpen(false)
|
||||
if (locale === currentLang) return
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
|
||||
@@ -64,85 +63,44 @@ export function LanguageDropdown() {
|
||||
router.push(newPath)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [isOpen])
|
||||
|
||||
// Reset hovered index when popover closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setHoveredIndex(-1)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const languageEntries = Object.entries(languages)
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
aria-haspopup='listbox'
|
||||
aria-expanded={isOpen}
|
||||
aria-controls='language-menu'
|
||||
className='flex cursor-pointer items-center gap-1.5 rounded-[6px] px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
style={{
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
}}
|
||||
>
|
||||
<span>{languages[currentLang as keyof typeof languages]?.name}</span>
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', isOpen && 'rotate-180')} />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className='flex cursor-pointer items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 text-[13px] text-foreground/50 transition-colors duration-200 hover:bg-neutral-100 hover:text-foreground/70 focus:outline-none dark:hover:bg-neutral-800 dark:hover:text-foreground/70'>
|
||||
<span>{languages[currentLang as keyof typeof languages]?.name}</span>
|
||||
<svg width='8' height='5' viewBox='0 0 10 6' fill='none' className='flex-shrink-0'>
|
||||
<path
|
||||
d='M1 1L5 5L9 1'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' sideOffset={6} className='min-w-[160px]'>
|
||||
{languageEntries.map(([code, lang]) => {
|
||||
const isSelected = currentLang === code
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className='fixed inset-0 z-[1000]' aria-hidden onClick={() => setIsOpen(false)} />
|
||||
<div
|
||||
id='language-menu'
|
||||
role='listbox'
|
||||
className='absolute top-full right-0 z-[1001] mt-2 max-h-[400px] min-w-[160px] overflow-auto rounded-[6px] bg-white px-[6px] py-[6px] shadow-lg dark:bg-neutral-900'
|
||||
>
|
||||
{languageEntries.map(([code, lang], index) => {
|
||||
const isSelected = currentLang === code
|
||||
const isHovered = hoveredIndex === index
|
||||
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleLanguageChange(code)
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(-1)}
|
||||
role='option'
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
'flex h-[26px] w-full min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] text-[13px] transition-colors',
|
||||
'text-neutral-700 dark:text-neutral-200',
|
||||
isHovered && 'bg-neutral-100 dark:bg-neutral-800',
|
||||
'focus:outline-none'
|
||||
)}
|
||||
>
|
||||
<span className='text-[13px]'>{lang.flag}</span>
|
||||
<span className='flex-1 text-left leading-none'>{lang.name}</span>
|
||||
{isSelected && <Check className='ml-auto h-3.5 w-3.5' />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 text-[13px]',
|
||||
isSelected && 'font-medium'
|
||||
)}
|
||||
>
|
||||
<span className='text-[13px]'>{lang.flag}</span>
|
||||
<span className='flex-1'>{lang.name}</span>
|
||||
{isSelected && <Check className='ml-auto h-3.5 w-3.5' />}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function Lightbox({ isOpen, onClose, src, alt, type }: LightboxProps) {
|
||||
aria-modal='true'
|
||||
aria-label='Media viewer'
|
||||
>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
|
||||
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl'>
|
||||
{type === 'image' ? (
|
||||
<img
|
||||
src={src}
|
||||
|
||||
@@ -15,7 +15,8 @@ export function SearchTrigger() {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-9 w-[360px] cursor-pointer items-center gap-2 rounded-lg border border-border/50 bg-fd-muted/50 px-3 text-[13px] text-fd-muted-foreground transition-colors hover:border-border hover:text-fd-foreground'
|
||||
data-search-trigger
|
||||
className='flex h-8 w-[360px] cursor-pointer items-center gap-2 rounded-lg border border-border/50 bg-fd-muted/50 px-3 text-[13px] text-fd-muted-foreground transition-colors hover:bg-fd-muted'
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Search className='h-3.5 w-3.5' />
|
||||
|
||||
@@ -56,53 +56,55 @@ export function SimLogo({ className }: SimLogoProps) {
|
||||
|
||||
/**
|
||||
* Full Sim logo with icon and "Sim" text.
|
||||
* Uses the same SVG source as the landing page navbar for exact visual alignment.
|
||||
* The icon stays green (#33C482), text adapts to light/dark mode.
|
||||
*/
|
||||
export function SimLogoFull({ className }: SimLogoProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='720 440 1020 320'
|
||||
viewBox='0 0 71 22'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className={cn('h-7 w-auto', className)}
|
||||
aria-label='Sim'
|
||||
>
|
||||
{/* Green icon - top left shape with cutout */}
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
|
||||
fill='#33C482'
|
||||
/>
|
||||
{/* Green icon - bottom right square */}
|
||||
<path
|
||||
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
|
||||
fill='#33C482'
|
||||
/>
|
||||
{/* Gradient overlay on bottom right square */}
|
||||
<path
|
||||
d='M1008.3 624.199H923.113C912.786 624.199 904.414 632.631 904.414 643.033V727.78C904.414 738.181 912.786 746.612 923.113 746.612H1008.3C1018.63 746.612 1027 738.181 1027 727.78V643.033C1027 632.631 1018.63 624.199 1008.3 624.199Z'
|
||||
fill='url(#sim-logo-full-gradient)'
|
||||
fillOpacity='0.2'
|
||||
/>
|
||||
{/* "Sim" text - adapts to light/dark mode via currentColor */}
|
||||
<path
|
||||
d='M1210.54 515.657C1226.65 515.657 1240.59 518.51 1252.31 524.257H1252.31C1264.3 529.995 1273.63 538.014 1280.26 548.319H1280.26C1287.19 558.635 1290.78 570.899 1291.08 585.068L1291.1 586.089H1249.11L1249.09 585.115C1248.8 574.003 1245.18 565.493 1238.32 559.451C1231.45 553.399 1221.79 550.308 1209.21 550.308C1196.3 550.308 1186.48 553.113 1179.61 558.588C1172.76 564.046 1169.33 571.499 1169.33 581.063C1169.33 588.092 1171.88 593.978 1177.01 598.783C1182.17 603.618 1189.99 607.399 1200.56 610.061H1200.56L1238.77 619.451C1257.24 623.65 1271.21 630.571 1280.57 640.293L1281.01 640.739C1290.13 650.171 1294.64 662.97 1294.64 679.016C1294.64 692.923 1290.88 705.205 1283.34 715.822L1283.33 715.834C1275.81 726.134 1265.44 734.14 1252.26 739.866L1252.25 739.871C1239.36 745.302 1224.12 748 1206.54 748C1180.9 748 1160.36 741.696 1145.02 728.984C1129.67 716.258 1122 699.269 1122 678.121V677.121H1163.99V678.121C1163.99 688.869 1167.87 697.367 1175.61 703.722L1176.34 704.284C1184.04 709.997 1194.37 712.902 1207.43 712.902C1222.13 712.902 1233.3 710.087 1241.07 704.588C1248.8 698.812 1252.64 691.21 1252.64 681.699C1252.64 674.769 1250.5 669.057 1246.25 664.49L1246.23 664.478L1246.22 664.464C1242.28 659.929 1234.83 656.119 1223.64 653.152L1185.43 644.208L1185.42 644.204C1166.05 639.407 1151.49 632.035 1141.83 622.012L1141.83 622.006L1141.82 622C1132.43 611.94 1127.78 598.707 1127.78 582.405C1127.78 568.81 1131.23 556.976 1138.17 546.949L1138.18 546.941L1138.19 546.933C1145.41 536.936 1155.18 529.225 1167.48 523.793L1167.48 523.79C1180.07 518.36 1194.43 515.657 1210.54 515.657ZM1323.39 521.979C1331.68 525.008 1337.55 526.482 1343.51 526.482C1349.48 526.482 1355.64 525.005 1364.49 521.973L1365.82 521.52V742.633H1322.05V521.489L1323.39 521.979ZM1642.01 515.657C1667.11 515.657 1686.94 523.031 1701.39 537.876C1715.83 552.716 1723 572.968 1723 598.507V742.633H1680.12V608.794C1680.12 591.666 1675.72 578.681 1667.07 569.681L1667.06 569.669L1667.04 569.656C1658.67 560.359 1647.26 555.675 1632.68 555.675C1622.47 555.675 1613.47 558.022 1605.64 562.69L1605.63 562.696C1598.11 567.064 1592.17 573.475 1587.8 581.968C1583.44 590.448 1581.25 600.424 1581.25 611.925V742.633H1537.92V608.347C1537.92 591.208 1533.67 578.376 1525.31 569.68L1525.31 569.674L1525.3 569.668C1516.93 560.664 1505.52 556.122 1490.93 556.122C1480.72 556.122 1471.72 558.469 1463.89 563.138L1463.88 563.144C1456.36 567.511 1450.41 573.922 1446.05 582.415L1446.05 582.422L1446.04 582.428C1441.69 590.602 1439.5 600.423 1439.5 611.925V742.633H1395.72V521.919H1435.05V554.803C1439.92 544.379 1447.91 535.465 1458.37 528.356C1470.71 519.875 1485.58 515.657 1502.93 515.657C1522.37 515.657 1538.61 520.931 1551.55 531.538C1560.38 538.771 1567.1 547.628 1571.72 558.091C1576.05 547.619 1582.83 538.757 1592.07 531.524C1605.61 520.93 1622.28 515.657 1642.01 515.657ZM1343.49 452C1351.45 452 1358.23 454.786 1363.75 460.346C1369.27 465.905 1372.04 472.721 1372.04 480.73C1372.04 488.452 1369.27 495.254 1363.77 501.096L1363.76 501.105L1363.75 501.115C1358.23 506.675 1351.45 509.461 1343.49 509.461C1335.81 509.461 1329.05 506.669 1323.25 501.134L1323.23 501.115L1323.21 501.096C1317.71 495.254 1314.94 488.452 1314.94 480.73C1314.94 472.721 1317.7 465.905 1323.23 460.346L1323.24 460.337L1323.25 460.327C1329.05 454.792 1335.81 452 1343.49 452Z'
|
||||
className='fill-neutral-900 dark:fill-white'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='sim-logo-full-gradient'
|
||||
x1='904.414'
|
||||
y1='624.199'
|
||||
x2='978.836'
|
||||
y2='698.447'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='171.406'
|
||||
y1='171.18'
|
||||
x2='245.831'
|
||||
y2='245.428'
|
||||
>
|
||||
<stop />
|
||||
<stop offset='0' />
|
||||
<stop offset='1' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Green icon — scaled to match landing logo proportions */}
|
||||
<g transform='scale(.07483)'>
|
||||
<path
|
||||
clipRule='evenodd'
|
||||
d='m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z'
|
||||
fill='#33C482'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z'
|
||||
fill='#33C482'
|
||||
/>
|
||||
<path
|
||||
d='m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z'
|
||||
fill='url(#sim-logo-full-gradient)'
|
||||
fillOpacity='0.2'
|
||||
/>
|
||||
</g>
|
||||
{/* "Sim" text — adapts to light/dark mode */}
|
||||
<g className='fill-neutral-900 dark:fill-white'>
|
||||
<path d='M31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z' />
|
||||
<path d='M44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z' />
|
||||
<path d='M51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { SVGProps } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
function SunIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<circle cx='12' cy='12' r='4' />
|
||||
<path d='M12 2v2' />
|
||||
<path d='M12 20v2' />
|
||||
<path d='m4.93 4.93 1.41 1.41' />
|
||||
<path d='m17.66 17.66 1.41 1.41' />
|
||||
<path d='M2 12h2' />
|
||||
<path d='M20 12h2' />
|
||||
<path d='m6.34 17.66-1.41 1.41' />
|
||||
<path d='m19.07 4.93-1.41 1.41' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d='M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
@@ -14,8 +60,8 @@ export function ThemeToggle() {
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button className='flex cursor-pointer items-center justify-center rounded-md p-1 text-muted-foreground'>
|
||||
<Moon className='h-4 w-4' />
|
||||
<button className='flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-full text-foreground/40'>
|
||||
<MoonIcon />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -23,10 +69,10 @@ export function ThemeToggle() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className='flex cursor-pointer items-center justify-center rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground'
|
||||
className='flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-full text-foreground/40 transition-colors duration-200 hover:bg-neutral-100 hover:text-foreground/70 dark:hover:bg-neutral-800 dark:hover:text-foreground/70'
|
||||
aria-label='Toggle theme'
|
||||
>
|
||||
{theme === 'dark' ? <Moon className='h-4 w-4' /> : <Sun className='h-4 w-4' />}
|
||||
{theme === 'dark' ? <MoonIcon /> : <SunIcon />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface VideoProps {
|
||||
|
||||
export function Video({
|
||||
src,
|
||||
className = 'w-full rounded-xl border border-border shadow-sm overflow-hidden outline-none focus:outline-none',
|
||||
className = 'w-full rounded-xl border border-border overflow-hidden outline-none focus:outline-none',
|
||||
autoPlay = true,
|
||||
loop = true,
|
||||
muted = true,
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
{
|
||||
"title": "Sim Documentation",
|
||||
"pages": [
|
||||
"---Getting Started---",
|
||||
"./introduction/index",
|
||||
"./getting-started/index",
|
||||
"./quick-reference/index",
|
||||
"---Building Workflows---",
|
||||
"triggers",
|
||||
"blocks",
|
||||
"tools",
|
||||
"connections",
|
||||
"---Features---",
|
||||
"mcp",
|
||||
"copilot",
|
||||
"skills",
|
||||
"knowledgebase",
|
||||
"variables",
|
||||
"credentials",
|
||||
"---Platform---",
|
||||
"execution",
|
||||
"permissions",
|
||||
"self-hosting",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"title": "API Reference",
|
||||
"root": true,
|
||||
"pages": [
|
||||
"---Getting Started---",
|
||||
"getting-started",
|
||||
"authentication",
|
||||
"---SDKs---",
|
||||
|
||||
@@ -93,17 +93,36 @@ Access resume data in downstream blocks using `<blockId.resumeInput.fieldName>`.
|
||||
<Tab>
|
||||
### REST API
|
||||
|
||||
Programmatically resume workflows:
|
||||
Programmatically resume workflows using the resume endpoint. The `contextId` is available from the block's `resumeEndpoint` output or from the paused execution detail.
|
||||
|
||||
```bash
|
||||
POST /api/workflows/{workflowId}/executions/{executionId}/resume/{blockId}
|
||||
POST /api/resume/{workflowId}/{executionId}/{contextId}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"approved": true,
|
||||
"comments": "Looks good to proceed"
|
||||
"input": {
|
||||
"approved": true,
|
||||
"comments": "Looks good to proceed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The response includes a new `executionId` for the resumed execution:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "started",
|
||||
"executionId": "<resumeExecutionId>",
|
||||
"message": "Resume execution started."
|
||||
}
|
||||
```
|
||||
|
||||
To poll execution progress after resuming, connect to the SSE stream:
|
||||
|
||||
```bash
|
||||
GET /api/workflows/{workflowId}/executions/{resumeExecutionId}/stream
|
||||
```
|
||||
|
||||
Build custom approval UIs or integrate with existing systems.
|
||||
</Tab>
|
||||
<Tab>
|
||||
|
||||
@@ -20,7 +20,7 @@ The Response block formats and sends structured HTTP responses back to API calle
|
||||
</div>
|
||||
|
||||
<Callout type="info">
|
||||
Response blocks are terminal blocks - they end workflow execution and cannot connect to other blocks.
|
||||
Response blocks are exit points — when a Response block executes, it ends the workflow and sends the HTTP response immediately. Multiple Response blocks can be placed on different branches (e.g. after a Router or Condition), but only the first one to execute determines the API response.
|
||||
</Callout>
|
||||
|
||||
## Configuration Options
|
||||
@@ -77,7 +77,11 @@ Condition (Error Detected) → Router → Response (400/500, Error Details)
|
||||
|
||||
## Outputs
|
||||
|
||||
Response blocks are terminal — no downstream blocks execute after them. However, the block does define outputs (`data`, `status`, `headers`) which are used to construct the HTTP response sent back to the API caller.
|
||||
Response blocks are exit points — when one executes, no further blocks run. The block defines outputs (`data`, `status`, `headers`) which are used to construct the HTTP response sent back to the API caller.
|
||||
|
||||
<Callout type="warning">
|
||||
If a Response block is placed on a parallel branch, there are no guarantees about whether other parallel blocks will run or not. Execution order across parallel branches is non-deterministic, so a parallel block may execute before or after the Response block on any given run. Avoid placing Response blocks in parallel with blocks that have important side effects.
|
||||
</Callout>
|
||||
|
||||
## Variable References
|
||||
|
||||
@@ -110,10 +114,10 @@ Use the `<variable.name>` syntax to dynamically insert workflow variables into y
|
||||
- **Validate variable references**: Ensure all referenced variables exist and contain the expected data types before the Response block executes
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Can I have multiple Response blocks in a workflow?", answer: "No. The Response block is a single-instance block — only one is allowed per workflow. If you need different responses for different conditions, use a Condition or Router block upstream to determine what data reaches the single Response block." },
|
||||
{ question: "Can I have multiple Response blocks in a workflow?", answer: "Yes. You can place multiple Response blocks on different branches (e.g. after a Router or Condition block). The first Response block to execute determines the API response and ends the workflow. This is useful for returning different responses based on conditions — for example, a 200 on the success branch and a 500 on the error branch." },
|
||||
{ question: "What triggers require a Response block?", answer: "The Response block is designed for use with the API Trigger. When your workflow is invoked via the API, the Response block sends the structured HTTP response back to the caller. Other trigger types (like webhooks or schedules) do not require a Response block." },
|
||||
{ question: "What is the difference between Builder and Editor mode?", answer: "Builder mode provides a visual interface for constructing your response structure with fields and types. Editor mode gives you a raw JSON code editor where you can write the response body directly. Builder mode is recommended for most use cases." },
|
||||
{ question: "What is the default status code?", answer: "If you do not specify a status code, the Response block defaults to 200 (OK). You can set any valid HTTP status code including error codes like 400, 404, or 500." },
|
||||
{ question: "Can the Response block connect to downstream blocks?", answer: "No. Response blocks are terminal — they end workflow execution and send the HTTP response. No further blocks can be connected after a Response block." },
|
||||
{ question: "Can the Response block connect to downstream blocks?", answer: "No. Response blocks are exit points — they end workflow execution and send the HTTP response. No further blocks can execute after a Response block." },
|
||||
]} />
|
||||
|
||||
|
||||
@@ -96,8 +96,9 @@ Understanding these core principles will help you build better workflows:
|
||||
2. **Automatic Parallelization**: Independent blocks run concurrently without configuration
|
||||
3. **Smart Data Flow**: Outputs flow automatically to connected blocks
|
||||
4. **Error Handling**: Failed blocks stop their execution path but don't affect independent paths
|
||||
5. **State Persistence**: All block outputs and execution details are preserved for debugging
|
||||
6. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
|
||||
5. **Response Blocks as Exit Points**: When a Response block executes, the entire workflow stops and the API response is sent immediately. Multiple Response blocks can exist on different branches — the first one to execute wins
|
||||
6. **State Persistence**: All block outputs and execution details are preserved for debugging
|
||||
7. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -27,10 +27,6 @@ import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
Build your first AI workflow in 10 minutes. In this tutorial, you'll create a people research agent that uses advanced LLM-powered search tools to extract and structure information about individuals.
|
||||
|
||||
<Callout type="info">
|
||||
This tutorial covers the essential concepts of building workflows in Sim. Estimated completion time: 10 minutes.
|
||||
</Callout>
|
||||
|
||||
## What You'll Build
|
||||
|
||||
A people research agent that:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Knowledgebase",
|
||||
"title": "Knowledge Base",
|
||||
"pages": ["index", "connectors", "tags"]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"title": "Sim Documentation",
|
||||
"pages": [
|
||||
"---Getting Started---",
|
||||
"./introduction/index",
|
||||
"./getting-started/index",
|
||||
"./quick-reference/index",
|
||||
"---Building Workflows---",
|
||||
"triggers",
|
||||
"blocks",
|
||||
"tools",
|
||||
"connections",
|
||||
"---Features---",
|
||||
"mcp",
|
||||
"copilot",
|
||||
"mailer",
|
||||
@@ -16,6 +19,7 @@
|
||||
"tables",
|
||||
"variables",
|
||||
"credentials",
|
||||
"---Platform---",
|
||||
"execution",
|
||||
"permissions",
|
||||
"self-hosting",
|
||||
|
||||
183
apps/docs/content/docs/en/tools/cloudformation.mdx
Normal file
183
apps/docs/content/docs/en/tools/cloudformation.mdx
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: CloudFormation
|
||||
description: Manage and inspect AWS CloudFormation stacks, resources, and drift
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="cloudformation"
|
||||
color="linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[AWS CloudFormation](https://aws.amazon.com/cloudformation/) is an infrastructure-as-code service that lets you model, provision, and manage AWS resources by treating infrastructure as code. CloudFormation uses templates to describe the resources you need and their dependencies, so you can launch and configure them together as a stack.
|
||||
|
||||
With the CloudFormation integration, you can:
|
||||
|
||||
- **Describe Stacks**: List all stacks in a region or get detailed information about a specific stack, including its status, outputs, tags, and drift information
|
||||
- **List Stack Resources**: Enumerate every resource in a stack with its logical ID, physical ID, type, status, and drift status
|
||||
- **Describe Stack Events**: View the full event history for a stack to understand what happened during create, update, or delete operations
|
||||
- **Detect Stack Drift**: Initiate drift detection to check whether any resources in a stack have been modified outside of CloudFormation
|
||||
- **Drift Detection Status**: Poll the results of a drift detection operation to see which resources have drifted and how many
|
||||
- **Get Template**: Retrieve the original template body (JSON or YAML) used to create or update a stack
|
||||
- **Validate Template**: Check a CloudFormation template for syntax errors, required capabilities, parameters, and declared transforms before deploying
|
||||
|
||||
In Sim, the CloudFormation integration enables your agents to monitor infrastructure state, detect configuration drift, audit stack resources, and validate templates as part of automated SRE and DevOps workflows. This is especially powerful when combined with CloudWatch for observability and SNS for alerting, creating end-to-end infrastructure monitoring pipelines.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate AWS CloudFormation into workflows. Describe stacks, list resources, detect drift, view stack events, retrieve templates, and validate templates. Requires AWS access key and secret access key.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `cloudformation_describe_stacks`
|
||||
|
||||
List and describe CloudFormation stacks
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackName` | string | No | Stack name or ID to describe \(omit to list all stacks\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `stacks` | array | List of CloudFormation stacks with status, outputs, and tags |
|
||||
|
||||
### `cloudformation_list_stack_resources`
|
||||
|
||||
List all resources in a CloudFormation stack
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackName` | string | Yes | Stack name or ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `resources` | array | List of stack resources with type, status, and drift information |
|
||||
|
||||
### `cloudformation_detect_stack_drift`
|
||||
|
||||
Initiate drift detection on a CloudFormation stack
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackName` | string | Yes | Stack name or ID to detect drift on |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `stackDriftDetectionId` | string | ID to use with Describe Stack Drift Detection Status to check results |
|
||||
|
||||
### `cloudformation_describe_stack_drift_detection_status`
|
||||
|
||||
Check the status of a stack drift detection operation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackDriftDetectionId` | string | Yes | The drift detection ID returned by Detect Stack Drift |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `stackId` | string | The stack ID |
|
||||
| `stackDriftDetectionId` | string | The drift detection ID |
|
||||
| `stackDriftStatus` | string | Drift status \(DRIFTED, IN_SYNC, NOT_CHECKED\) |
|
||||
| `detectionStatus` | string | Detection status \(DETECTION_IN_PROGRESS, DETECTION_COMPLETE, DETECTION_FAILED\) |
|
||||
| `detectionStatusReason` | string | Reason if detection failed |
|
||||
| `driftedStackResourceCount` | number | Number of resources that have drifted |
|
||||
| `timestamp` | number | Timestamp of the detection |
|
||||
|
||||
### `cloudformation_describe_stack_events`
|
||||
|
||||
Get the event history for a CloudFormation stack
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackName` | string | Yes | Stack name or ID |
|
||||
| `limit` | number | No | Maximum number of events to return \(default: 50\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `events` | array | List of stack events with resource status and timestamps |
|
||||
|
||||
### `cloudformation_get_template`
|
||||
|
||||
Retrieve the template body for a CloudFormation stack
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackName` | string | Yes | Stack name or ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `templateBody` | string | The template body as a JSON or YAML string |
|
||||
| `stagesAvailable` | array | Available template stages |
|
||||
|
||||
### `cloudformation_validate_template`
|
||||
|
||||
Validate a CloudFormation template for syntax and structural correctness
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `templateBody` | string | Yes | The CloudFormation template body \(JSON or YAML\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `description` | string | Template description |
|
||||
| `parameters` | array | Template parameters with defaults and descriptions |
|
||||
| `capabilities` | array | Required capabilities \(e.g., CAPABILITY_IAM\) |
|
||||
| `capabilitiesReason` | string | Reason capabilities are required |
|
||||
| `declaredTransforms` | array | Transforms used in the template \(e.g., AWS::Serverless-2016-10-31\) |
|
||||
|
||||
|
||||
180
apps/docs/content/docs/en/tools/cloudwatch.mdx
Normal file
180
apps/docs/content/docs/en/tools/cloudwatch.mdx
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
title: CloudWatch
|
||||
description: Query and monitor AWS CloudWatch logs, metrics, and alarms
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="cloudwatch"
|
||||
color="linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate AWS CloudWatch into workflows. Run Log Insights queries, list log groups, retrieve log events, list and get metrics, and monitor alarms. Requires AWS access key and secret access key.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `cloudwatch_query_logs`
|
||||
|
||||
Run a CloudWatch Log Insights query against one or more log groups
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `logGroupNames` | array | Yes | Log group names to query |
|
||||
| `queryString` | string | Yes | CloudWatch Log Insights query string |
|
||||
| `startTime` | number | Yes | Start time as Unix epoch seconds |
|
||||
| `endTime` | number | Yes | End time as Unix epoch seconds |
|
||||
| `limit` | number | No | Maximum number of results to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | array | Query result rows |
|
||||
| `statistics` | object | Query statistics \(bytesScanned, recordsMatched, recordsScanned\) |
|
||||
| `status` | string | Query completion status |
|
||||
|
||||
### `cloudwatch_describe_log_groups`
|
||||
|
||||
List available CloudWatch log groups
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `prefix` | string | No | Filter log groups by name prefix |
|
||||
| `limit` | number | No | Maximum number of log groups to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `logGroups` | array | List of CloudWatch log groups with metadata |
|
||||
|
||||
### `cloudwatch_get_log_events`
|
||||
|
||||
Retrieve log events from a specific CloudWatch log stream
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `logGroupName` | string | Yes | CloudWatch log group name |
|
||||
| `logStreamName` | string | Yes | CloudWatch log stream name |
|
||||
| `startTime` | number | No | Start time as Unix epoch seconds |
|
||||
| `endTime` | number | No | End time as Unix epoch seconds |
|
||||
| `limit` | number | No | Maximum number of events to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `events` | array | Log events with timestamp, message, and ingestion time |
|
||||
|
||||
### `cloudwatch_describe_log_streams`
|
||||
|
||||
List log streams within a CloudWatch log group
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `logGroupName` | string | Yes | CloudWatch log group name |
|
||||
| `prefix` | string | No | Filter log streams by name prefix |
|
||||
| `limit` | number | No | Maximum number of log streams to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `logStreams` | array | List of log streams with metadata |
|
||||
|
||||
### `cloudwatch_list_metrics`
|
||||
|
||||
List available CloudWatch metrics
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `namespace` | string | No | Filter by namespace \(e.g., AWS/EC2, AWS/Lambda\) |
|
||||
| `metricName` | string | No | Filter by metric name |
|
||||
| `recentlyActive` | boolean | No | Only show metrics active in the last 3 hours |
|
||||
| `limit` | number | No | Maximum number of metrics to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `metrics` | array | List of metrics with namespace, name, and dimensions |
|
||||
|
||||
### `cloudwatch_get_metric_statistics`
|
||||
|
||||
Get statistics for a CloudWatch metric over a time range
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `namespace` | string | Yes | Metric namespace \(e.g., AWS/EC2, AWS/Lambda\) |
|
||||
| `metricName` | string | Yes | Metric name \(e.g., CPUUtilization, Invocations\) |
|
||||
| `startTime` | number | Yes | Start time as Unix epoch seconds |
|
||||
| `endTime` | number | Yes | End time as Unix epoch seconds |
|
||||
| `period` | number | Yes | Granularity in seconds \(e.g., 60, 300, 3600\) |
|
||||
| `statistics` | array | Yes | Statistics to retrieve \(Average, Sum, Minimum, Maximum, SampleCount\) |
|
||||
| `dimensions` | string | No | Dimensions as JSON \(e.g., \{"InstanceId": "i-1234"\}\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `label` | string | Metric label |
|
||||
| `datapoints` | array | Datapoints with timestamp and statistics values |
|
||||
|
||||
### `cloudwatch_describe_alarms`
|
||||
|
||||
List and filter CloudWatch alarms
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `alarmNamePrefix` | string | No | Filter alarms by name prefix |
|
||||
| `stateValue` | string | No | Filter by alarm state \(OK, ALARM, INSUFFICIENT_DATA\) |
|
||||
| `alarmType` | string | No | Filter by alarm type \(MetricAlarm, CompositeAlarm\) |
|
||||
| `limit` | number | No | Maximum number of alarms to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `alarms` | array | List of CloudWatch alarms with state and configuration |
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ List all cloud agents for the authenticated user with optional pagination. Retur
|
||||
| `apiKey` | string | Yes | Cursor API key |
|
||||
| `limit` | number | No | Number of agents to return \(default: 20, max: 100\) |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `prUrl` | string | No | Filter agents by pull request URL |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -173,4 +174,41 @@ Permanently delete a cloud agent. Returns API-aligned fields only.
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Agent ID |
|
||||
|
||||
### `cursor_list_artifacts`
|
||||
|
||||
List generated artifact files for a cloud agent. Returns API-aligned fields only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Cursor API key |
|
||||
| `agentId` | string | Yes | Unique identifier for the cloud agent \(e.g., bc_abc123\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `artifacts` | array | List of artifact files |
|
||||
| ↳ `path` | string | Artifact file path |
|
||||
| ↳ `size` | number | File size in bytes |
|
||||
|
||||
### `cursor_download_artifact`
|
||||
|
||||
Download a generated artifact file from a cloud agent. Returns the file for execution storage.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Cursor API key |
|
||||
| `agentId` | string | Yes | Unique identifier for the cloud agent \(e.g., bc_abc123\) |
|
||||
| `path` | string | Yes | Absolute path of the artifact to download \(e.g., /src/index.ts\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | file | Downloaded artifact file stored in execution files |
|
||||
|
||||
|
||||
|
||||
343
apps/docs/content/docs/en/tools/dagster.mdx
Normal file
343
apps/docs/content/docs/en/tools/dagster.mdx
Normal file
@@ -0,0 +1,343 @@
|
||||
---
|
||||
title: Dagster
|
||||
description: Orchestrate data pipelines and manage job runs with Dagster
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="dagster"
|
||||
color="#ffffff"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Dagster](https://dagster.io/) is an open-source data orchestration platform designed for building, testing, and monitoring data pipelines. It provides a unified model for defining data assets, scheduling jobs, and observing pipeline execution — whether running locally or deployed to Dagster+.
|
||||
|
||||
With Dagster, you can:
|
||||
|
||||
- **Orchestrate data pipelines**: Define and run jobs composed of ops and assets with full dependency tracking
|
||||
- **Monitor executions**: Track run status, inspect logs, and debug failures step by step
|
||||
- **Manage schedules and sensors**: Automate pipeline triggers on a cron schedule or in response to external events
|
||||
- **Reexecute selectively**: Resume failed pipelines from the point of failure without rerunning successful steps
|
||||
|
||||
In Sim, the Dagster integration enables your agents to interact with a Dagster instance programmatically. Agents can launch and monitor job runs, retrieve execution logs, reexecute failed runs, and manage schedules and sensors — all as part of a larger automated workflow. Use Dagster as an orchestration layer your agents can control and observe, enabling data-driven automation that responds dynamically to pipeline outcomes.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to a Dagster instance to launch job runs, monitor run status, list available jobs across repositories, terminate or delete runs, reexecute failed runs, fetch run logs, and manage schedules and sensors. API token only required for Dagster+.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `dagster_launch_run`
|
||||
|
||||
Launch a job run on a Dagster instance.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3000\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `repositoryLocationName` | string | Yes | Repository location \(code location\) name |
|
||||
| `repositoryName` | string | Yes | Repository name within the code location |
|
||||
| `jobName` | string | Yes | Name of the job to launch |
|
||||
| `runConfigJson` | string | No | Run configuration as a JSON object \(optional\) |
|
||||
| `tags` | string | No | Tags as a JSON array of \{key, value\} objects \(optional\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `runId` | string | The globally unique ID of the launched run |
|
||||
|
||||
### `dagster_get_run`
|
||||
|
||||
Get the status and details of a Dagster run by its ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3000\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `runId` | string | Yes | The ID of the run to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `runId` | string | Run ID |
|
||||
| `jobName` | string | Name of the job this run belongs to |
|
||||
| `status` | string | Run status \(QUEUED, NOT_STARTED, STARTING, MANAGED, STARTED, SUCCESS, FAILURE, CANCELING, CANCELED\) |
|
||||
| `startTime` | number | Run start time as Unix timestamp |
|
||||
| `endTime` | number | Run end time as Unix timestamp |
|
||||
| `runConfigYaml` | string | Run configuration as YAML |
|
||||
| `tags` | json | Run tags as array of \{key, value\} objects |
|
||||
|
||||
### `dagster_get_run_logs`
|
||||
|
||||
Fetch execution event logs for a Dagster run.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `runId` | string | Yes | The ID of the run to fetch logs for |
|
||||
| `afterCursor` | string | No | Cursor for paginating through log events \(from a previous response\) |
|
||||
| `limit` | number | No | Maximum number of log events to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `events` | json | Array of log events \(type, message, timestamp, level, stepKey, eventType\) |
|
||||
| ↳ `type` | string | GraphQL typename of the event |
|
||||
| ↳ `message` | string | Human-readable log message |
|
||||
| ↳ `timestamp` | string | Event timestamp as a Unix epoch string |
|
||||
| ↳ `level` | string | Log level \(DEBUG, INFO, WARNING, ERROR, CRITICAL\) |
|
||||
| ↳ `stepKey` | string | Step key, if the event is step-scoped |
|
||||
| ↳ `eventType` | string | Dagster event type enum value |
|
||||
| `cursor` | string | Cursor for fetching the next page of log events |
|
||||
| `hasMore` | boolean | Whether more log events are available beyond this page |
|
||||
|
||||
### `dagster_list_runs`
|
||||
|
||||
List recent Dagster runs, optionally filtered by job name.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `jobName` | string | No | Filter runs by job name \(optional\) |
|
||||
| `statuses` | string | No | Comma-separated run statuses to filter by, e.g. "SUCCESS,FAILURE" \(optional\) |
|
||||
| `limit` | number | No | Maximum number of runs to return \(default 20\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `runs` | json | Array of runs |
|
||||
| ↳ `runId` | string | Run ID |
|
||||
| ↳ `jobName` | string | Job name |
|
||||
| ↳ `status` | string | Run status |
|
||||
| ↳ `tags` | json | Run tags as array of \{key, value\} objects |
|
||||
| ↳ `startTime` | number | Start time as Unix timestamp |
|
||||
| ↳ `endTime` | number | End time as Unix timestamp |
|
||||
|
||||
### `dagster_list_jobs`
|
||||
|
||||
List all jobs across repositories in a Dagster instance.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `jobs` | json | Array of jobs with name and repositoryName |
|
||||
| ↳ `name` | string | Job name |
|
||||
| ↳ `repositoryName` | string | Repository name |
|
||||
|
||||
### `dagster_reexecute_run`
|
||||
|
||||
Reexecute an existing Dagster run, optionally resuming only from failed steps.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `parentRunId` | string | Yes | The ID of the run to reexecute |
|
||||
| `strategy` | string | Yes | Reexecution strategy: ALL_STEPS reruns everything, FROM_FAILURE resumes from failed steps, FROM_ASSET_FAILURE resumes from failed assets |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `runId` | string | The ID of the newly launched reexecution run |
|
||||
|
||||
### `dagster_terminate_run`
|
||||
|
||||
Terminate an in-progress Dagster run.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `runId` | string | Yes | The ID of the run to terminate |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the run was successfully terminated |
|
||||
| `runId` | string | The ID of the terminated run |
|
||||
| `message` | string | Error or status message if termination failed |
|
||||
|
||||
### `dagster_delete_run`
|
||||
|
||||
Permanently delete a Dagster run record.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `runId` | string | Yes | The ID of the run to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `runId` | string | The ID of the deleted run |
|
||||
|
||||
### `dagster_list_schedules`
|
||||
|
||||
List all schedules in a Dagster repository, optionally filtered by status.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `repositoryLocationName` | string | Yes | Repository location \(code location\) name |
|
||||
| `repositoryName` | string | Yes | Repository name within the code location |
|
||||
| `scheduleStatus` | string | No | Filter schedules by status: RUNNING or STOPPED \(omit to return all\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `schedules` | json | Array of schedules \(name, cronSchedule, jobName, status, id, description, executionTimezone\) |
|
||||
| ↳ `name` | string | Schedule name |
|
||||
| ↳ `cronSchedule` | string | Cron expression for the schedule |
|
||||
| ↳ `jobName` | string | Job the schedule targets |
|
||||
| ↳ `status` | string | Schedule status: RUNNING or STOPPED |
|
||||
| ↳ `id` | string | Instigator state ID — use this to start or stop the schedule |
|
||||
| ↳ `description` | string | Human-readable schedule description |
|
||||
| ↳ `executionTimezone` | string | Timezone for cron evaluation |
|
||||
|
||||
### `dagster_start_schedule`
|
||||
|
||||
Enable (start) a schedule in a Dagster repository.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `repositoryLocationName` | string | Yes | Repository location \(code location\) name |
|
||||
| `repositoryName` | string | Yes | Repository name within the code location |
|
||||
| `scheduleName` | string | Yes | Name of the schedule to start |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Instigator state ID of the schedule |
|
||||
| `status` | string | Updated schedule status \(RUNNING or STOPPED\) |
|
||||
|
||||
### `dagster_stop_schedule`
|
||||
|
||||
Disable (stop) a running schedule in Dagster.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `instigationStateId` | string | Yes | InstigationState ID of the schedule to stop — available from dagster_list_schedules output |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Instigator state ID of the schedule |
|
||||
| `status` | string | Updated schedule status \(RUNNING or STOPPED\) |
|
||||
|
||||
### `dagster_list_sensors`
|
||||
|
||||
List all sensors in a Dagster repository, optionally filtered by status.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `repositoryLocationName` | string | Yes | Repository location \(code location\) name |
|
||||
| `repositoryName` | string | Yes | Repository name within the code location |
|
||||
| `sensorStatus` | string | No | Filter sensors by status: RUNNING or STOPPED \(omit to return all\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sensors` | json | Array of sensors \(name, sensorType, status, id, description\) |
|
||||
| ↳ `name` | string | Sensor name |
|
||||
| ↳ `sensorType` | string | Sensor type \(ASSET, AUTO_MATERIALIZE, FRESHNESS_POLICY, MULTI_ASSET, RUN_STATUS, STANDARD\) |
|
||||
| ↳ `status` | string | Sensor status: RUNNING or STOPPED |
|
||||
| ↳ `id` | string | Instigator state ID — use this to start or stop the sensor |
|
||||
| ↳ `description` | string | Human-readable sensor description |
|
||||
|
||||
### `dagster_start_sensor`
|
||||
|
||||
Enable (start) a sensor in a Dagster repository.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `repositoryLocationName` | string | Yes | Repository location \(code location\) name |
|
||||
| `repositoryName` | string | Yes | Repository name within the code location |
|
||||
| `sensorName` | string | Yes | Name of the sensor to start |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Instigator state ID of the sensor |
|
||||
| `status` | string | Updated sensor status \(RUNNING or STOPPED\) |
|
||||
|
||||
### `dagster_stop_sensor`
|
||||
|
||||
Disable (stop) a running sensor in Dagster.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) |
|
||||
| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) |
|
||||
| `instigationStateId` | string | Yes | InstigationState ID of the sensor to stop — available from dagster_list_sensors output |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Instigator state ID of the sensor |
|
||||
| `status` | string | Updated sensor status \(RUNNING or STOPPED\) |
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ Integrate Extend AI into the workflow. Parse and extract structured content from
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Unique identifier for the parser run |
|
||||
| `status` | string | Processing status |
|
||||
| `chunks` | json | Parsed document content chunks |
|
||||
| `blocks` | json | Block-level document elements with type and content |
|
||||
| `pageCount` | number | Number of pages processed |
|
||||
| `creditsUsed` | number | API credits consumed |
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Interact with Linear issues, projects, and more
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="linear"
|
||||
type="linear_v2"
|
||||
color="#5E6AD2"
|
||||
/>
|
||||
|
||||
|
||||
@@ -23,8 +23,11 @@
|
||||
"clay",
|
||||
"clerk",
|
||||
"cloudflare",
|
||||
"cloudformation",
|
||||
"cloudwatch",
|
||||
"confluence",
|
||||
"cursor",
|
||||
"dagster",
|
||||
"databricks",
|
||||
"datadog",
|
||||
"devin",
|
||||
@@ -148,6 +151,7 @@
|
||||
"sharepoint",
|
||||
"shopify",
|
||||
"similarweb",
|
||||
"sixtyfour",
|
||||
"slack",
|
||||
"smtp",
|
||||
"sqs",
|
||||
|
||||
@@ -54,8 +54,27 @@ Integrate Mistral Parse into the workflow. Can extract text from uploaded PDF do
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `pages` | array | Array of page objects from Mistral OCR |
|
||||
| `model` | string | Mistral OCR model identifier |
|
||||
| `usage_info` | json | Usage statistics from the API |
|
||||
| `document_annotation` | string | Structured annotation data |
|
||||
| ↳ `index` | number | Page index \(zero-based\) |
|
||||
| ↳ `markdown` | string | Extracted markdown content |
|
||||
| ↳ `images` | array | Images extracted from this page with bounding boxes |
|
||||
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
|
||||
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
|
||||
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
|
||||
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
|
||||
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
|
||||
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
|
||||
| ↳ `dimensions` | object | Page dimensions |
|
||||
| ↳ `dpi` | number | Dots per inch |
|
||||
| ↳ `height` | number | Page height in pixels |
|
||||
| ↳ `width` | number | Page width in pixels |
|
||||
| ↳ `tables` | array | Extracted tables as HTML/markdown \(when table_format is set\). Referenced via placeholders like \[tbl-0.html\] |
|
||||
| ↳ `hyperlinks` | array | Array of URL strings detected in the page \(e.g., \["https://...", "mailto:..."\]\) |
|
||||
| ↳ `header` | string | Page header content \(when extract_header=true\) |
|
||||
| ↳ `footer` | string | Page footer content \(when extract_footer=true\) |
|
||||
| `model` | string | Mistral OCR model identifier \(e.g., mistral-ocr-latest\) |
|
||||
| `usage_info` | object | Usage and processing statistics |
|
||||
| ↳ `pages_processed` | number | Total number of pages processed |
|
||||
| ↳ `doc_size_bytes` | number | Document file size in bytes |
|
||||
| `document_annotation` | string | Structured annotation data as JSON string \(when applicable\) |
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,16 @@ Integrate Pulse into the workflow. Extract text from PDF documents, images, and
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `markdown` | string | Extracted content in markdown format |
|
||||
| `page_count` | number | Number of pages in the document |
|
||||
| `job_id` | string | Unique job identifier |
|
||||
| `bounding_boxes` | json | Bounding box layout information |
|
||||
| `extraction_url` | string | URL for extraction results \(for large documents\) |
|
||||
| `html` | string | HTML content if requested |
|
||||
| `structured_output` | json | Structured output if schema was provided |
|
||||
| `chunks` | json | Chunked content if chunking was enabled |
|
||||
| `figures` | json | Extracted figures if figure extraction was enabled |
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,13 @@ Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF do
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `job_id` | string | Unique identifier for the processing job |
|
||||
| `duration` | number | Processing time in seconds |
|
||||
| `usage` | json | Resource consumption data |
|
||||
| `result` | json | Parsed document content with chunks and blocks |
|
||||
| `pdf_url` | string | Storage URL of converted PDF |
|
||||
| `studio_link` | string | Link to Reducto studio interface |
|
||||
|
||||
|
||||
|
||||
128
apps/docs/content/docs/en/tools/sixtyfour.mdx
Normal file
128
apps/docs/content/docs/en/tools/sixtyfour.mdx
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Sixtyfour AI
|
||||
description: Enrich leads and companies with AI-powered research
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="sixtyfour"
|
||||
color="#000000"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Find emails, phone numbers, and enrich lead or company data with contact information, social profiles, and detailed research using Sixtyfour AI.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `sixtyfour_find_phone`
|
||||
|
||||
Find phone numbers for a lead using Sixtyfour AI.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Sixtyfour API key |
|
||||
| `name` | string | Yes | Full name of the person |
|
||||
| `company` | string | No | Company name |
|
||||
| `linkedinUrl` | string | No | LinkedIn profile URL |
|
||||
| `domain` | string | No | Company website domain |
|
||||
| `email` | string | No | Email address |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `name` | string | Name of the person |
|
||||
| `company` | string | Company name |
|
||||
| `phone` | string | Phone number\(s\) found |
|
||||
| `linkedinUrl` | string | LinkedIn profile URL |
|
||||
|
||||
### `sixtyfour_find_email`
|
||||
|
||||
Find email addresses for a lead using Sixtyfour AI.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Sixtyfour API key |
|
||||
| `name` | string | Yes | Full name of the person |
|
||||
| `company` | string | No | Company name |
|
||||
| `linkedinUrl` | string | No | LinkedIn profile URL |
|
||||
| `domain` | string | No | Company website domain |
|
||||
| `phone` | string | No | Phone number |
|
||||
| `title` | string | No | Job title |
|
||||
| `mode` | string | No | Email discovery mode: PROFESSIONAL \(default\) or PERSONAL |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `name` | string | Name of the person |
|
||||
| `company` | string | Company name |
|
||||
| `title` | string | Job title |
|
||||
| `phone` | string | Phone number |
|
||||
| `linkedinUrl` | string | LinkedIn profile URL |
|
||||
| `emails` | json | Professional email addresses found |
|
||||
| ↳ `address` | string | Email address |
|
||||
| ↳ `status` | string | Validation status \(OK or UNKNOWN\) |
|
||||
| ↳ `type` | string | Email type \(COMPANY or PERSONAL\) |
|
||||
| `personalEmails` | json | Personal email addresses found \(only in PERSONAL mode\) |
|
||||
| ↳ `address` | string | Email address |
|
||||
| ↳ `status` | string | Validation status \(OK or UNKNOWN\) |
|
||||
| ↳ `type` | string | Email type \(COMPANY or PERSONAL\) |
|
||||
|
||||
### `sixtyfour_enrich_lead`
|
||||
|
||||
Enrich lead information with contact details, social profiles, and company data using Sixtyfour AI.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Sixtyfour API key |
|
||||
| `leadInfo` | string | Yes | Lead information as JSON object with key-value pairs \(e.g. name, company, title, linkedin\) |
|
||||
| `struct` | string | Yes | Fields to collect as JSON object. Keys are field names, values are descriptions \(e.g. \{"email": "The individual\'s email address", "phone": "Phone number"\}\) |
|
||||
| `researchPlan` | string | No | Optional research plan to guide enrichment strategy |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `notes` | string | Research notes about the lead |
|
||||
| `structuredData` | json | Enriched lead data matching the requested struct fields |
|
||||
| `references` | json | Source URLs and descriptions used for enrichment |
|
||||
| `confidenceScore` | number | Quality score for the returned data \(0-10\) |
|
||||
|
||||
### `sixtyfour_enrich_company`
|
||||
|
||||
Enrich company data with additional information and find associated people using Sixtyfour AI.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Sixtyfour API key |
|
||||
| `targetCompany` | string | Yes | Company data as JSON object \(e.g. \{"name": "Acme Inc", "domain": "acme.com"\}\) |
|
||||
| `struct` | string | Yes | Fields to collect as JSON object. Keys are field names, values are descriptions \(e.g. \{"website": "Company website URL", "num_employees": "Employee count"\}\) |
|
||||
| `findPeople` | boolean | No | Whether to find people associated with the company |
|
||||
| `fullOrgChart` | boolean | No | Whether to retrieve the full organizational chart |
|
||||
| `researchPlan` | string | No | Optional strategy describing how the agent should search for information |
|
||||
| `peopleFocusPrompt` | string | No | Description of people to find \(roles, responsibilities\) |
|
||||
| `leadStruct` | string | No | Custom schema for returned lead data as JSON object |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `notes` | string | Research notes about the company |
|
||||
| `structuredData` | json | Enriched company data matching the requested struct fields |
|
||||
| `references` | json | Source URLs and descriptions used for enrichment |
|
||||
| `confidenceScore` | number | Quality score for the returned data \(0-10\) |
|
||||
|
||||
|
||||
@@ -69,7 +69,17 @@ Transcribe audio and video files to text using leading AI providers. Supports mu
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | string | Full transcribed text |
|
||||
| `segments` | array | Timestamped segments |
|
||||
| ↳ `text` | string | Transcribed text for this segment |
|
||||
| ↳ `start` | number | Start time in seconds |
|
||||
| ↳ `end` | number | End time in seconds |
|
||||
| ↳ `speaker` | string | Speaker identifier \(if diarization enabled\) |
|
||||
| ↳ `confidence` | number | Confidence score \(0-1\) |
|
||||
| `language` | string | Detected or specified language |
|
||||
| `duration` | number | Audio duration in seconds |
|
||||
|
||||
### `stt_deepgram`
|
||||
|
||||
@@ -89,7 +99,18 @@ This tool does not produce any outputs.
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | string | Full transcribed text |
|
||||
| `segments` | array | Timestamped segments with speaker labels |
|
||||
| ↳ `text` | string | Transcribed text for this segment |
|
||||
| ↳ `start` | number | Start time in seconds |
|
||||
| ↳ `end` | number | End time in seconds |
|
||||
| ↳ `speaker` | string | Speaker identifier \(if diarization enabled\) |
|
||||
| ↳ `confidence` | number | Confidence score \(0-1\) |
|
||||
| `language` | string | Detected or specified language |
|
||||
| `duration` | number | Audio duration in seconds |
|
||||
| `confidence` | number | Overall confidence score |
|
||||
|
||||
### `stt_elevenlabs`
|
||||
|
||||
@@ -108,7 +129,13 @@ This tool does not produce any outputs.
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | string | Full transcribed text |
|
||||
| `segments` | array | Timestamped segments |
|
||||
| `language` | string | Detected or specified language |
|
||||
| `duration` | number | Audio duration in seconds |
|
||||
| `confidence` | number | Overall confidence score |
|
||||
|
||||
### `stt_assemblyai`
|
||||
|
||||
@@ -132,7 +159,30 @@ This tool does not produce any outputs.
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | string | Full transcribed text |
|
||||
| `segments` | array | Timestamped segments with speaker labels |
|
||||
| ↳ `text` | string | Transcribed text for this segment |
|
||||
| ↳ `start` | number | Start time in seconds |
|
||||
| ↳ `end` | number | End time in seconds |
|
||||
| ↳ `speaker` | string | Speaker identifier \(if diarization enabled\) |
|
||||
| ↳ `confidence` | number | Confidence score \(0-1\) |
|
||||
| `language` | string | Detected or specified language |
|
||||
| `duration` | number | Audio duration in seconds |
|
||||
| `confidence` | number | Overall confidence score |
|
||||
| `sentiment` | array | Sentiment analysis results |
|
||||
| ↳ `text` | string | Text that was analyzed |
|
||||
| ↳ `sentiment` | string | Sentiment \(POSITIVE, NEGATIVE, NEUTRAL\) |
|
||||
| ↳ `confidence` | number | Confidence score |
|
||||
| ↳ `start` | number | Start time in milliseconds |
|
||||
| ↳ `end` | number | End time in milliseconds |
|
||||
| `entities` | array | Detected entities |
|
||||
| ↳ `entity_type` | string | Entity type \(e.g., person_name, location, organization\) |
|
||||
| ↳ `text` | string | Entity text |
|
||||
| ↳ `start` | number | Start time in milliseconds |
|
||||
| ↳ `end` | number | End time in milliseconds |
|
||||
| `summary` | string | Auto-generated summary |
|
||||
|
||||
### `stt_gemini`
|
||||
|
||||
@@ -151,6 +201,12 @@ This tool does not produce any outputs.
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | string | Full transcribed text |
|
||||
| `segments` | array | Timestamped segments |
|
||||
| `language` | string | Detected or specified language |
|
||||
| `duration` | number | Audio duration in seconds |
|
||||
| `confidence` | number | Overall confidence score |
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,39 @@ Integrate AWS Textract into your workflow to extract text, tables, forms, and ke
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `blocks` | array | Array of Block objects containing detected text, tables, forms, and other elements |
|
||||
| ↳ `BlockType` | string | Type of block \(PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.\) |
|
||||
| ↳ `Id` | string | Unique identifier for the block |
|
||||
| ↳ `Text` | string | The text content \(for LINE and WORD blocks\) |
|
||||
| ↳ `TextType` | string | Type of text \(PRINTED or HANDWRITING\) |
|
||||
| ↳ `Confidence` | number | Confidence score \(0-100\) |
|
||||
| ↳ `Page` | number | Page number |
|
||||
| ↳ `Geometry` | object | Location and bounding box information |
|
||||
| ↳ `BoundingBox` | object | Height as ratio of document height |
|
||||
| ↳ `Height` | number | Height as ratio of document height |
|
||||
| ↳ `Left` | number | Left position as ratio of document width |
|
||||
| ↳ `Top` | number | Top position as ratio of document height |
|
||||
| ↳ `Width` | number | Width as ratio of document width |
|
||||
| ↳ `Polygon` | array | Polygon coordinates |
|
||||
| ↳ `X` | number | X coordinate |
|
||||
| ↳ `Y` | number | Y coordinate |
|
||||
| ↳ `Relationships` | array | Relationships to other blocks |
|
||||
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
|
||||
| ↳ `Ids` | array | IDs of related blocks |
|
||||
| ↳ `EntityTypes` | array | Entity types for KEY_VALUE_SET \(KEY or VALUE\) |
|
||||
| ↳ `SelectionStatus` | string | For checkboxes: SELECTED or NOT_SELECTED |
|
||||
| ↳ `RowIndex` | number | Row index for table cells |
|
||||
| ↳ `ColumnIndex` | number | Column index for table cells |
|
||||
| ↳ `RowSpan` | number | Row span for merged cells |
|
||||
| ↳ `ColumnSpan` | number | Column span for merged cells |
|
||||
| ↳ `Query` | object | Query information for QUERY blocks |
|
||||
| ↳ `Text` | string | Query text |
|
||||
| ↳ `Alias` | string | Query alias |
|
||||
| ↳ `Pages` | array | Pages to search |
|
||||
| `documentMetadata` | object | Metadata about the analyzed document |
|
||||
| ↳ `pages` | number | Number of pages in the document |
|
||||
| `modelVersion` | string | Version of the Textract model used for processing |
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,14 @@ Integrate Vision into the workflow. Can analyze images with vision models.
|
||||
|
||||
#### Output
|
||||
|
||||
This tool does not produce any outputs.
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | The analyzed content and description of the image |
|
||||
| `model` | string | The vision model that was used for analysis |
|
||||
| `tokens` | number | Total tokens used for the analysis |
|
||||
| `usage` | object | Detailed token usage breakdown |
|
||||
| ↳ `input_tokens` | number | Tokens used for input processing |
|
||||
| ↳ `output_tokens` | number | Tokens used for response generation |
|
||||
| ↳ `total_tokens` | number | Total tokens consumed |
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
{
|
||||
"title": "Sim Documentation",
|
||||
"pages": [
|
||||
"---Getting Started---",
|
||||
"./introduction/index",
|
||||
"./getting-started/index",
|
||||
"./quick-reference/index",
|
||||
"---Building Workflows---",
|
||||
"triggers",
|
||||
"blocks",
|
||||
"tools",
|
||||
"connections",
|
||||
"---Features---",
|
||||
"mcp",
|
||||
"copilot",
|
||||
"skills",
|
||||
"knowledgebase",
|
||||
"variables",
|
||||
"credentials",
|
||||
"---Platform---",
|
||||
"execution",
|
||||
"permissions",
|
||||
"self-hosting",
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
{
|
||||
"title": "Sim Documentation",
|
||||
"pages": [
|
||||
"---Getting Started---",
|
||||
"./introduction/index",
|
||||
"./getting-started/index",
|
||||
"./quick-reference/index",
|
||||
"---Building Workflows---",
|
||||
"triggers",
|
||||
"blocks",
|
||||
"tools",
|
||||
"connections",
|
||||
"---Features---",
|
||||
"mcp",
|
||||
"copilot",
|
||||
"skills",
|
||||
"knowledgebase",
|
||||
"variables",
|
||||
"credentials",
|
||||
"---Platform---",
|
||||
"execution",
|
||||
"permissions",
|
||||
"self-hosting",
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
{
|
||||
"title": "Sim Documentation",
|
||||
"pages": [
|
||||
"---Getting Started---",
|
||||
"./introduction/index",
|
||||
"./getting-started/index",
|
||||
"./quick-reference/index",
|
||||
"---Building Workflows---",
|
||||
"triggers",
|
||||
"blocks",
|
||||
"tools",
|
||||
"connections",
|
||||
"---Features---",
|
||||
"mcp",
|
||||
"copilot",
|
||||
"skills",
|
||||
"knowledgebase",
|
||||
"variables",
|
||||
"credentials",
|
||||
"---Platform---",
|
||||
"execution",
|
||||
"permissions",
|
||||
"self-hosting",
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
{
|
||||
"title": "Sim Documentation",
|
||||
"pages": [
|
||||
"---Getting Started---",
|
||||
"./introduction/index",
|
||||
"./getting-started/index",
|
||||
"./quick-reference/index",
|
||||
"---Building Workflows---",
|
||||
"triggers",
|
||||
"blocks",
|
||||
"tools",
|
||||
"connections",
|
||||
"---Features---",
|
||||
"mcp",
|
||||
"copilot",
|
||||
"skills",
|
||||
"knowledgebase",
|
||||
"variables",
|
||||
"credentials",
|
||||
"---Platform---",
|
||||
"execution",
|
||||
"permissions",
|
||||
"self-hosting",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { twMerge as cn } from 'tailwind-merge'
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createMDX } from 'fumadocs-mdx/next'
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const withMDX = createMDX()
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
const config: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
webpackMemoryOptimizations: true,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 2.1 MiB |
@@ -33,7 +33,7 @@
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#33C482",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
"categories": ["productivity", "developer", "business"],
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="129.434" x2="185.629" y1="129.266" y2="185.33"><stop offset="0"/><stop offset="1" stop-opacity="0"/></linearGradient><rect fill="#0b0b0b" height="16" rx="3" width="16"/><g transform="matrix(.0473 0 0 .0473 2.75 2.75)"><path clip-rule="evenodd" d="m107.822 93.7612c0 3.5869-1.419 7.0308-3.938 9.5668l-.361.364c-2.517 2.544-5.9375 3.966-9.4994 3.966h-80.5781c-7.42094 0-13.4455 6.06-13.4455 13.533v87.141c0 7.474 6.02456 13.534 13.4455 13.534h86.5167c7.4208 0 13.4378-6.06 13.4378-13.534v-81.587c0-3.326 1.31-6.517 3.647-8.871 2.33-2.347 5.499-3.667 8.802-3.667h81.928c7.421 0 13.437-6.059 13.437-13.533v-87.1407c0-7.47374-6.016-13.5333-13.437-13.5333h-86.517c-7.421 0-13.438 6.05956-13.438 13.5333zm26.256-75.2112h60.874c4.337 0 7.844 3.5393 7.844 7.9003v61.3071c0 4.3604-3.507 7.9003-7.844 7.9003h-60.874c-4.33 0-7.845-3.5399-7.845-7.9003v-61.3071c0-4.361 3.515-7.9003 7.845-7.9003z" fill="#33c482" fill-rule="evenodd"/><path d="m207.878 129.57h-64.324c-7.798 0-14.12 6.367-14.12 14.221v63.993c0 7.854 6.322 14.221 14.12 14.221h64.324c7.799 0 14.121-6.367 14.121-14.221v-63.993c0-7.854-6.322-14.221-14.121-14.221z" fill="#33c482"/><path d="m207.878 129.266h-64.324c-7.798 0-14.12 6.366-14.12 14.221v63.992c0 7.854 6.322 14.22 14.12 14.22h64.324c7.799 0 14.121-6.366 14.121-14.22v-63.992c0-7.855-6.322-14.221-14.121-14.221z" fill="url(#a)" fill-opacity=".2"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -9,8 +9,4 @@ export const docs = defineDocs({
|
||||
},
|
||||
})
|
||||
|
||||
export default defineConfig({
|
||||
mdxOptions: {
|
||||
// MDX options
|
||||
},
|
||||
})
|
||||
export default defineConfig()
|
||||
|
||||
@@ -4,10 +4,48 @@ These rules apply to files under `apps/sim/` in addition to the repository root
|
||||
|
||||
## 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.
|
||||
### 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-Level 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
|
||||
```
|
||||
|
||||
### Feature Organization
|
||||
|
||||
Features live under `app/workspace/[workspaceId]/`:
|
||||
|
||||
```
|
||||
feature/
|
||||
├── components/ # Feature components
|
||||
├── hooks/ # Feature-scoped hooks
|
||||
├── utils/ # Feature-scoped utilities (2+ consumers)
|
||||
├── feature.tsx # Main component
|
||||
└── page.tsx # Next.js page entry
|
||||
```
|
||||
|
||||
### 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 And Types
|
||||
|
||||
@@ -31,3 +69,10 @@ These rules apply to files under `apps/sim/` in addition to the repository root
|
||||
- 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`.
|
||||
|
||||
## Utils Rules
|
||||
|
||||
- **Never create `utils.ts` for single consumer** - inline it
|
||||
- **Create `utils.ts` when** 2+ files need the same helper
|
||||
- **Check existing sources** before duplicating (`lib/` has many utilities)
|
||||
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
|
||||
export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { isGithubAuthDisabled, isGoogleAuthDisabled, isProd } from '@/lib/core/config/feature-flags'
|
||||
|
||||
export async function getOAuthProviderStatus() {
|
||||
const githubAvailable = !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET)
|
||||
const githubAvailable =
|
||||
!!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) && !isGithubAuthDisabled
|
||||
|
||||
const googleAvailable = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET)
|
||||
const googleAvailable =
|
||||
!!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) && !isGoogleAuthDisabled
|
||||
|
||||
return { githubAvailable, googleAvailable, isProduction: isProd }
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function SocialLoginButtons({
|
||||
const githubButton = (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full rounded-[10px]'
|
||||
className='w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm'
|
||||
disabled={!githubAvailable || isGithubLoading}
|
||||
onClick={signInWithGithub}
|
||||
>
|
||||
@@ -93,7 +93,7 @@ export function SocialLoginButtons({
|
||||
const googleButton = (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full rounded-[10px]'
|
||||
className='w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm'
|
||||
disabled={!googleAvailable || isGoogleLoading}
|
||||
onClick={signInWithGoogle}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,9 @@ export function SSOLoginButton({
|
||||
router.push(ssoUrl)
|
||||
}
|
||||
|
||||
const outlineBtnClasses = cn('w-full rounded-[10px]')
|
||||
const outlineBtnClasses = cn(
|
||||
'w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm'
|
||||
)
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
import { SupportFooter } from './support-footer'
|
||||
|
||||
export interface StatusPageLayoutProps {
|
||||
|
||||
@@ -97,49 +97,49 @@ export function SetNewPasswordForm({
|
||||
}: SetNewPasswordFormProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [validationMessage, setValidationMessage] = useState('')
|
||||
const [validationMessages, setValidationMessages] = useState<string[]>([])
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
if (password.length < 8) {
|
||||
setValidationMessage('Password must be at least 8 characters long')
|
||||
return
|
||||
errors.push('Password must be at least 8 characters long')
|
||||
}
|
||||
|
||||
if (password.length > 100) {
|
||||
setValidationMessage('Password must not exceed 100 characters')
|
||||
return
|
||||
errors.push('Password must not exceed 100 characters')
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
setValidationMessage('Password must contain at least one uppercase letter')
|
||||
return
|
||||
errors.push('Password must contain at least one uppercase letter')
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
setValidationMessage('Password must contain at least one lowercase letter')
|
||||
return
|
||||
errors.push('Password must contain at least one lowercase letter')
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
setValidationMessage('Password must contain at least one number')
|
||||
return
|
||||
errors.push('Password must contain at least one number')
|
||||
}
|
||||
|
||||
if (!/[^A-Za-z0-9]/.test(password)) {
|
||||
setValidationMessage('Password must contain at least one special character')
|
||||
return
|
||||
errors.push('Password must contain at least one special character')
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setValidationMessage('Passwords do not match')
|
||||
errors.push('Passwords do not match')
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setValidationMessages(errors)
|
||||
return
|
||||
}
|
||||
|
||||
setValidationMessage('')
|
||||
setValidationMessages([])
|
||||
onSubmit(password)
|
||||
}
|
||||
|
||||
@@ -162,7 +162,10 @@ export function SetNewPasswordForm({
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Enter new password'
|
||||
className={cn('pr-10', validationMessage && 'border-red-500 focus:border-red-500')}
|
||||
className={cn(
|
||||
'pr-10',
|
||||
validationMessages.length > 0 && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
@@ -190,7 +193,10 @@ export function SetNewPasswordForm({
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Confirm new password'
|
||||
className={cn('pr-10', validationMessage && 'border-red-500 focus:border-red-500')}
|
||||
className={cn(
|
||||
'pr-10',
|
||||
validationMessages.length > 0 && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
@@ -203,9 +209,11 @@ export function SetNewPasswordForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationMessage && (
|
||||
{validationMessages.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
<p>{validationMessage}</p>
|
||||
{validationMessages.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -99,7 +99,11 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
|
||||
}, [])
|
||||
const redirectUrl = useMemo(
|
||||
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
|
||||
[searchParams]
|
||||
@@ -228,18 +232,6 @@ function SignupFormContent({
|
||||
emailValidationErrors.length > 0 ||
|
||||
errors.length > 0
|
||||
) {
|
||||
if (nameValidationErrors.length > 0) {
|
||||
setNameErrors([nameValidationErrors[0]])
|
||||
setShowNameValidationError(true)
|
||||
}
|
||||
if (emailValidationErrors.length > 0) {
|
||||
setEmailErrors([emailValidationErrors[0]])
|
||||
setShowEmailValidationError(true)
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
setPasswordErrors([errors[0]])
|
||||
setShowValidationError(true)
|
||||
}
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -261,6 +253,9 @@ function SignupFormContent({
|
||||
widget.execute()
|
||||
token = await widget.getResponsePromise()
|
||||
} catch {
|
||||
captureEvent(posthog, 'signup_failed', {
|
||||
error_code: 'captcha_client_failure',
|
||||
})
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
@@ -284,7 +279,9 @@ function SignupFormContent({
|
||||
logger.error('Signup error:', ctx.error)
|
||||
const errorMessage: string[] = ['Failed to create account']
|
||||
|
||||
let errorCode = 'unknown'
|
||||
if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) {
|
||||
errorCode = 'user_already_exists'
|
||||
errorMessage.push(
|
||||
'An account with this email already exists. Please sign in instead.'
|
||||
)
|
||||
@@ -293,24 +290,30 @@ function SignupFormContent({
|
||||
ctx.error.code?.includes('BAD_REQUEST') ||
|
||||
ctx.error.message?.includes('Email and password sign up is not enabled')
|
||||
) {
|
||||
errorCode = 'signup_disabled'
|
||||
errorMessage.push('Email signup is currently disabled.')
|
||||
setEmailError(errorMessage[0])
|
||||
} else if (ctx.error.code?.includes('INVALID_EMAIL')) {
|
||||
errorCode = 'invalid_email'
|
||||
errorMessage.push('Please enter a valid email address.')
|
||||
setEmailError(errorMessage[0])
|
||||
} else if (ctx.error.code?.includes('PASSWORD_TOO_SHORT')) {
|
||||
errorCode = 'password_too_short'
|
||||
errorMessage.push('Password must be at least 8 characters long.')
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
} else if (ctx.error.code?.includes('PASSWORD_TOO_LONG')) {
|
||||
errorCode = 'password_too_long'
|
||||
errorMessage.push('Password must be less than 128 characters long.')
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
} else if (ctx.error.code?.includes('network')) {
|
||||
errorCode = 'network_error'
|
||||
errorMessage.push('Network error. Please check your connection and try again.')
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
} else if (ctx.error.code?.includes('rate limit')) {
|
||||
errorCode = 'rate_limited'
|
||||
errorMessage.push('Too many requests. Please wait a moment before trying again.')
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
@@ -318,6 +321,8 @@ function SignupFormContent({
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
}
|
||||
|
||||
captureEvent(posthog, 'signup_failed', { error_code: errorCode })
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -400,7 +405,7 @@ function SignupFormContent({
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 left-0 z-10 grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
'grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
showNameValidationError && nameErrors.length > 0
|
||||
? 'grid-rows-[1fr]'
|
||||
: 'grid-rows-[0fr]'
|
||||
@@ -438,7 +443,7 @@ function SignupFormContent({
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 left-0 z-10 grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
'grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
(showEmailValidationError && emailErrors.length > 0) ||
|
||||
(emailError && !showEmailValidationError)
|
||||
? 'grid-rows-[1fr]'
|
||||
@@ -497,7 +502,7 @@ function SignupFormContent({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 left-0 z-10 grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
'grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
showValidationError && passwordErrors.length > 0
|
||||
? 'grid-rows-[1fr]'
|
||||
: 'grid-rows-[0fr]'
|
||||
|
||||
@@ -1,584 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
|
||||
/** Stagger between each block appearing (seconds). */
|
||||
const ENTER_STAGGER = 0.06
|
||||
|
||||
/** Duration of each block's fade-in (seconds). */
|
||||
const ENTER_DURATION = 0.3
|
||||
|
||||
/** Stagger between each block disappearing (seconds). */
|
||||
const EXIT_STAGGER = 0.12
|
||||
|
||||
/** Duration of each block's fade-out (seconds). */
|
||||
const EXIT_DURATION = 0.5
|
||||
|
||||
/** Shared corner radius for all decorative rects. */
|
||||
const RX = '2.59574'
|
||||
|
||||
/** Hold time after the initial enter animation before cycling starts (ms). */
|
||||
const INITIAL_HOLD_MS = 2500
|
||||
|
||||
/** Pause between an exit completing and the next enter starting (ms). */
|
||||
const TRANSITION_PAUSE_MS = 400
|
||||
|
||||
/** Hold time between successive transitions (ms). */
|
||||
const HOLD_BETWEEN_MS = 2500
|
||||
|
||||
/** Animation state for a block group. */
|
||||
export type BlockAnimState = 'entering' | 'visible' | 'exiting' | 'hidden'
|
||||
|
||||
/** Positions around the hero where block groups can appear. */
|
||||
export type BlockPosition = 'topRight' | 'left' | 'rightEdge' | 'rightSide' | 'topLeft'
|
||||
|
||||
/** Attributes for a single animated SVG rect. */
|
||||
interface BlockRect {
|
||||
opacity: number
|
||||
width: string
|
||||
height: string
|
||||
fill: string
|
||||
x?: string
|
||||
y?: string
|
||||
transform?: string
|
||||
}
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: ENTER_STAGGER } },
|
||||
exit: { transition: { staggerChildren: EXIT_STAGGER } },
|
||||
}
|
||||
|
||||
const blockVariants: Variants = {
|
||||
hidden: { opacity: 0, transition: { duration: 0 } },
|
||||
visible: (targetOpacity: number) => ({
|
||||
opacity: targetOpacity,
|
||||
transition: { duration: ENTER_DURATION },
|
||||
}),
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { duration: EXIT_DURATION },
|
||||
},
|
||||
}
|
||||
|
||||
/** Maps a BlockAnimState to the framer-motion animate value. */
|
||||
function toAnimateValue(state: BlockAnimState): string {
|
||||
if (state === 'entering' || state === 'visible') return 'visible'
|
||||
if (state === 'exiting') return 'exit'
|
||||
return 'hidden'
|
||||
}
|
||||
|
||||
/** Shared SVG wrapper that staggers child rects in and out. */
|
||||
function AnimatedBlocksSvg({
|
||||
width,
|
||||
height,
|
||||
viewBox,
|
||||
rects,
|
||||
animState = 'entering',
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
viewBox: string
|
||||
rects: readonly BlockRect[]
|
||||
animState?: BlockAnimState
|
||||
}) {
|
||||
return (
|
||||
<motion.svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={viewBox}
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
initial='hidden'
|
||||
animate={toAnimateValue(animState)}
|
||||
variants={containerVariants}
|
||||
>
|
||||
{rects.map((r, i) => (
|
||||
<motion.rect
|
||||
key={i}
|
||||
variants={blockVariants}
|
||||
custom={r.opacity}
|
||||
x={r.x}
|
||||
y={r.y}
|
||||
width={r.width}
|
||||
height={r.height}
|
||||
rx={RX}
|
||||
fill={r.fill}
|
||||
transform={r.transform}
|
||||
/>
|
||||
))}
|
||||
</motion.svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rect data for the top-right position.
|
||||
* Two-row horizontal strip, ordered left-to-right.
|
||||
*/
|
||||
const TOP_RIGHT_RECTS: readonly BlockRect[] = [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the top-left position.
|
||||
* Same two-row structure as top-right with rotated colour palette:
|
||||
* blue→green, green→yellow, yellow→pink, pink→blue.
|
||||
*/
|
||||
const TOP_LEFT_RECTS: readonly BlockRect[] = [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
|
||||
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the left position.
|
||||
* Two-column vertical strip, ordered top-to-bottom.
|
||||
*/
|
||||
const LEFT_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.480',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.986',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '140.507',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '34.240',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 0.5,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the right-side position (right edge of screenshot).
|
||||
* Same two-column structure as left with rotated colours:
|
||||
* pink→blue, green→pink, yellow→green.
|
||||
*/
|
||||
const RIGHT_SIDE_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.480',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.986',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '140.507',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '34.240',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 0.5,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Rect data for the right-edge position (far right of screen).
|
||||
* Two-column vertical strip, ordered top-to-bottom.
|
||||
*/
|
||||
const RIGHT_RECTS: readonly BlockRect[] = [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.241',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 16.891 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.482',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.739 16.888)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 33.776)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.739 34.272)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.012 68.510)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '102.384',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.787 102.384)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '34.241',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
},
|
||||
]
|
||||
|
||||
/** Number of rects per position, used to compute animation durations. */
|
||||
const RECT_COUNTS: Record<BlockPosition, number> = {
|
||||
topRight: TOP_RIGHT_RECTS.length,
|
||||
topLeft: TOP_LEFT_RECTS.length,
|
||||
left: LEFT_RECTS.length,
|
||||
rightSide: RIGHT_SIDE_RECTS.length,
|
||||
rightEdge: RIGHT_RECTS.length,
|
||||
}
|
||||
|
||||
/** Total enter animation time for a position (seconds). */
|
||||
function enterTime(pos: BlockPosition): number {
|
||||
return (RECT_COUNTS[pos] - 1) * ENTER_STAGGER + ENTER_DURATION
|
||||
}
|
||||
|
||||
/** Total exit animation time for a position (seconds). */
|
||||
function exitTime(pos: BlockPosition): number {
|
||||
return (RECT_COUNTS[pos] - 1) * EXIT_STAGGER + EXIT_DURATION
|
||||
}
|
||||
|
||||
/** A single step in the repeating animation cycle. */
|
||||
type CycleStep =
|
||||
| { action: 'exit'; position: BlockPosition }
|
||||
| { action: 'enter'; position: BlockPosition }
|
||||
| { action: 'hold'; ms: number }
|
||||
|
||||
/**
|
||||
* The repeating cycle sequence. After all steps, the layout returns to its
|
||||
* initial state (topRight + left + rightEdge) so the loop is seamless.
|
||||
*
|
||||
* Order: exit top → exit right-edge → enter right-side-of-preview →
|
||||
* exit left → enter top-left → exit right-side → enter left →
|
||||
* exit top-left → enter top-right → enter right-edge → back to initial.
|
||||
*/
|
||||
const CYCLE_STEPS: readonly CycleStep[] = [
|
||||
{ action: 'exit', position: 'topRight' },
|
||||
{ action: 'exit', position: 'rightEdge' },
|
||||
{ action: 'enter', position: 'rightSide' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'left' },
|
||||
{ action: 'enter', position: 'topLeft' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'rightSide' },
|
||||
{ action: 'enter', position: 'left' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'exit', position: 'topLeft' },
|
||||
{ action: 'enter', position: 'topRight' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
{ action: 'enter', position: 'rightEdge' },
|
||||
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||
]
|
||||
|
||||
/**
|
||||
* Drives the block-cycling animation loop. Returns the current animation
|
||||
* state for every position so each component can be driven declaratively.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. All three initial groups (topRight, left, rightEdge) enter together.
|
||||
* 2. After a hold period the cycle begins, processing each step in order.
|
||||
* 3. Repeats indefinitely, returning to the initial layout every cycle.
|
||||
*/
|
||||
export function useBlockCycle(): Record<BlockPosition, BlockAnimState> {
|
||||
const [states, setStates] = useState<Record<BlockPosition, BlockAnimState>>({
|
||||
topRight: 'entering',
|
||||
left: 'entering',
|
||||
rightEdge: 'entering',
|
||||
rightSide: 'hidden',
|
||||
topLeft: 'hidden',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const cancelled = { current: false }
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const run = async () => {
|
||||
const longestEnter = Math.max(
|
||||
enterTime('topRight'),
|
||||
enterTime('left'),
|
||||
enterTime('rightEdge')
|
||||
)
|
||||
await delay(longestEnter * 1000)
|
||||
if (cancelled.current) return
|
||||
|
||||
setStates({
|
||||
topRight: 'visible',
|
||||
left: 'visible',
|
||||
rightEdge: 'visible',
|
||||
rightSide: 'hidden',
|
||||
topLeft: 'hidden',
|
||||
})
|
||||
|
||||
await delay(INITIAL_HOLD_MS)
|
||||
if (cancelled.current) return
|
||||
|
||||
while (!cancelled.current) {
|
||||
for (const step of CYCLE_STEPS) {
|
||||
if (cancelled.current) return
|
||||
|
||||
if (step.action === 'exit') {
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'exiting' }))
|
||||
await delay(exitTime(step.position) * 1000)
|
||||
if (cancelled.current) return
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'hidden' }))
|
||||
await delay(TRANSITION_PAUSE_MS)
|
||||
} else if (step.action === 'enter') {
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'entering' }))
|
||||
await delay(enterTime(step.position) * 1000)
|
||||
if (cancelled.current) return
|
||||
setStates((prev) => ({ ...prev, [step.position]: 'visible' }))
|
||||
await delay(TRANSITION_PAUSE_MS)
|
||||
} else {
|
||||
await delay(step.ms)
|
||||
}
|
||||
|
||||
if (cancelled.current) return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
return () => {
|
||||
cancelled.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return states
|
||||
}
|
||||
|
||||
interface AnimatedBlockProps {
|
||||
animState?: BlockAnimState
|
||||
}
|
||||
|
||||
/** Two-row horizontal strip at the top-right of the hero. */
|
||||
export function BlocksTopRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
rects={TOP_RIGHT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-row horizontal strip at the top-left of the hero. */
|
||||
export function BlocksTopLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
rects={TOP_LEFT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip on the left edge of the screenshot. */
|
||||
export function BlocksLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
rects={LEFT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip on the right edge of the screenshot. */
|
||||
export function BlocksRightSideAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
rects={RIGHT_SIDE_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two-column vertical strip at the far-right edge of the screen. */
|
||||
export function BlocksRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||
return (
|
||||
<AnimatedBlocksSvg
|
||||
width={34}
|
||||
height={205}
|
||||
viewBox='0 0 34 204.769'
|
||||
rects={RIGHT_RECTS}
|
||||
animState={animState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client'
|
||||
|
||||
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,
|
||||
BlocksRightSideAnimated,
|
||||
BlocksTopLeftAnimated,
|
||||
BlocksTopRightAnimated,
|
||||
useBlockCycle,
|
||||
} from '@/app/(home)/components/hero/components/animated-blocks'
|
||||
|
||||
const LandingPreview = dynamic(
|
||||
() =>
|
||||
import('@/app/(home)/components/landing-preview/landing-preview').then(
|
||||
(mod) => mod.LandingPreview
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className='aspect-[1116/549] w-full rounded bg-[var(--landing-bg)]' />,
|
||||
}
|
||||
)
|
||||
|
||||
/** Shared base classes for CTA link buttons — matches Deploy/Run button styling in the preview panel. */
|
||||
const CTA_BASE =
|
||||
'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm'
|
||||
|
||||
export default function Hero() {
|
||||
const blockStates = useBlockCycle()
|
||||
|
||||
return (
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[var(--landing-bg)] pt-[60px] pb-3 lg:pt-[100px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
|
||||
1,000+ integrations and LLMs — including OpenAI, Claude, Gemini, Mistral, and xAI — to
|
||||
deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables,
|
||||
and docs. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 and
|
||||
SOC2 compliant.
|
||||
</p>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw]'
|
||||
>
|
||||
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-2.8vw] right-[-4vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||
>
|
||||
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 flex flex-col items-center gap-3'>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
className='text-balance font-[430] font-season text-[36px] text-white leading-[100%] tracking-[-0.02em] sm:text-[48px] lg:text-[72px]'
|
||||
>
|
||||
Build AI Agents
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[125%] tracking-[0.02em] lg:text-lg'>
|
||||
Sim is the AI Workspace for Agent Builders.
|
||||
</p>
|
||||
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<DemoRequestModal>
|
||||
<button
|
||||
type='button'
|
||||
className={`${CTA_BASE} border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]`}
|
||||
aria-label='Get a demo'
|
||||
>
|
||||
Get a demo
|
||||
</button>
|
||||
</DemoRequestModal>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BASE} gap-2 border-white bg-white text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 right-[13.1vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||
>
|
||||
<BlocksTopRightAnimated animState={blockStates.topRight} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-0 left-[16vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||
>
|
||||
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 mx-auto mt-[3.2vw] w-[78.9vw] px-[1.4vw]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
>
|
||||
<BlocksLeftAnimated animState={blockStates.left} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] left-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px] scale-x-[-1]'
|
||||
>
|
||||
<BlocksRightSideAnimated animState={blockStates.rightSide} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 overflow-hidden rounded border border-[var(--landing-bg-elevated)]'>
|
||||
<LandingPreview />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
>
|
||||
<BlocksRightAnimated animState={blockStates.rightEdge} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import Collaboration from '@/app/(home)/components/collaboration/collaboration'
|
||||
import Enterprise from '@/app/(home)/components/enterprise/enterprise'
|
||||
import Features from '@/app/(home)/components/features/features'
|
||||
import Footer from '@/app/(home)/components/footer/footer'
|
||||
import Hero from '@/app/(home)/components/hero/hero'
|
||||
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||
import Pricing from '@/app/(home)/components/pricing/pricing'
|
||||
import StructuredData from '@/app/(home)/components/structured-data'
|
||||
import Templates from '@/app/(home)/components/templates/templates'
|
||||
import Testimonials from '@/app/(home)/components/testimonials/testimonials'
|
||||
|
||||
export {
|
||||
Collaboration,
|
||||
Enterprise,
|
||||
Features,
|
||||
Footer,
|
||||
Hero,
|
||||
Navbar,
|
||||
Pricing,
|
||||
StructuredData,
|
||||
Templates,
|
||||
Testimonials,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user