| name | add-integration |
| description | Add a complete Sim integration from API docs, covering tools, block, icon, optional triggers, registrations, and integration conventions. Use when introducing a new service under `apps/sim/tools`, `apps/sim/blocks`, and `apps/sim/triggers`. |
Add Integration Skill
You are an expert at adding complete integrations to Sim. This skill orchestrates the full process of adding a new service integration.
Overview
Adding an integration involves these steps in order:
- Research - Read the service's API documentation
- Create Tools - Build tool configurations for each API operation
- Create Block - Build the block UI configuration
- Add Icon - Add the service's brand icon
- Create Triggers (optional) - If the service supports webhooks
- Register - Register tools, block, and triggers in their registries
- Generate Docs - Run the docs generation script
Step 1: Research the API
Before writing any code:
- Use Context7 to find official documentation:
mcp__plugin_context7_context7__resolve-library-id
- Or use WebFetch to read API docs directly
- Identify:
- Authentication method (OAuth, API Key, both)
- Available operations (CRUD, search, etc.)
- Required vs optional parameters
- Response structures
Hard Rule: No Guessed Response Schemas
If the official docs do not clearly show the response JSON shape for an endpoint, you MUST stop and tell the user exactly which outputs are unknown.
- Do NOT guess response field names
- Do NOT infer nested JSON paths from related endpoints
- Do NOT invent output properties just because they seem likely
- Do NOT implement
transformResponse against unverified payload shapes
If response schemas are missing or incomplete, do one of the following before proceeding:
- Ask the user for sample responses
- Ask the user for test credentials so you can verify the live payload
- Reduce the scope to only endpoints whose response shapes are documented
- Leave the tool unimplemented and explicitly report why
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:
import type { ToolResponse } from '@/tools/types'
export interface {Service}{Action}Params {
accessToken: string
apiKey: string
requiredParam: string
optionalParam?: string
}
export interface {Service}Response extends ToolResponse {
output: {
}
}
Tool file pattern:
export const {service}{Action}Tool: ToolConfig<Params, Response> = {
id: '{service}_{action}',
name: '{Service} {Action}',
description: '...',
version: '1.0.0',
oauth: { required: true, provider: '{service}' },
params: {
accessToken: { type: 'string', required: true, visibility: 'hidden', description: '...' },
},
request: { url, method, headers, body },
transformResponse: async (response) => {
const data = await response.json()
return {
success: true,
output: {
field: data.field ?? null,
},
}
},
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
- If you do not know the response JSON shape from docs or verified examples, you MUST tell the user and stop. Never guess outputs or response mappings.
Step 3: Create Block
File Location
apps/sim/blocks/blocks/{service}.ts
Block Structure
import { {Service}Icon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { getScopesForService } from '@/lib/oauth/utils'
export const {Service}Block: BlockConfig = {
type: '{service}',
name: '{Service}',
description: '...',
longDescription: '...',
docsLink: 'https://docs.sim.ai/tools/{service}',
category: 'tools',
bgColor: '#HEXCOLOR',
icon: {Service}Icon,
authMode: AuthMode.OAuth,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Operation 1', id: 'action1' },
{ label: 'Operation 2', id: 'action2' },
],
value: () => 'action1',
},
{
id: 'credential',
title: '{Service} Account',
type: 'oauth-input',
serviceId: '{service}',
requiredScopes: getScopesForService('{service}'),
required: true,
},
],
tools: {
access: ['{service}_action1', '{service}_action2'],
config: {
tool: (params) => `{service}_${params.operation}`,
},
},
outputs: { },
}
Key SubBlock Patterns
Condition-based visibility:
{
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:
{
id: 'project',
type: 'project-selector',
dependsOn: ['credential'],
},
{
id: 'issue',
type: 'file-selector',
dependsOn: ['credential', 'project'],
}
Basic/Advanced mode for dual UX:
{
id: 'channel',
type: 'channel-selector',
mode: 'basic',
canonicalParamId: 'channel',
dependsOn: ['credential'],
},
{
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
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:
- Extract the SVG paths/content
- Create a React component that spreads props
- 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
import { buildTriggerSubBlocks } from '@/triggers'
import { {service}TriggerOptions, {service}SetupInstructions, build{Service}ExtraFields } from './utils'
export const {service}EventATrigger: TriggerConfig = {
id: '{service}_event_a',
subBlocks: buildTriggerSubBlocks({
triggerId: '{service}_event_a',
triggerOptions: {service}TriggerOptions,
includeDropdown: true,
setupInstructions: {service}SetupInstructions('Event A'),
extraFields: build{Service}ExtraFields('{service}_event_a'),
}),
}
export const {service}EventBTrigger: TriggerConfig = {
id: '{service}_event_b',
subBlocks: buildTriggerSubBlocks({
triggerId: '{service}_event_b',
triggerOptions: {service}TriggerOptions,
setupInstructions: {service}SetupInstructions('Event B'),
extraFields: build{Service}ExtraFields('{service}_event_b'),
}),
}
Connect to Block
import { getTrigger } from '@/triggers'
export const {Service}Block: BlockConfig = {
triggers: {
enabled: true,
available: ['{service}_event_a', '{service}_event_b'],
},
subBlocks: [
...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)
import {
{service}Action1Tool,
{service}Action2Tool,
} from '@/tools/{service}'
export const tools: Record<string, ToolConfig> = {
{service}_action1: {service}Action1Tool,
{service}_action2: {service}Action2Tool,
}
Block Registry (apps/sim/blocks/registry.ts)
import { {Service}Block } from '@/blocks/blocks/{service}'
export const registry: Record<string, BlockConfig> = {
{service}: {Service}Block,
}
Trigger Registry (apps/sim/triggers/registry.ts) - If triggers exist
import {
{service}EventATrigger,
{service}EventBTrigger,
{service}WebhookTrigger,
} from '@/triggers/{service}'
export const TRIGGER_REGISTRY: TriggerRegistry = {
{service}_event_a: {service}EventATrigger,
{service}_event_b: {service}EventBTrigger,
{service}_webhook: {service}WebhookTrigger,
}
Step 7: Generate Docs
Run the documentation generator:
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):
- V2 Tools - Add
_v2 suffix, version 2.0.0, flat outputs
- V2 Block - Add
_v2 type, use createVersionedToolSelector
- V1 Block - Add
(Legacy) to name, set hideFromToolbar: true
- Registry - Register both versions
{service}: {Service}Block,
{service}_v2: {Service}V2Block,
Complete Checklist
Tools
Block
OAuth Scopes (if OAuth service)
Icon
Triggers (if service supports webhooks)
Docs
Final Validation (Required)
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:
interface UserFile {
id: string
name: string
url: string
size: number
type: string
base64?: string
key?: string
context?: object
}
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:
{
id: 'uploadFile',
title: 'File',
type: 'file-upload',
canonicalParamId: 'file',
placeholder: 'Upload file',
mode: 'basic',
multiple: false,
required: true,
condition: { field: 'operation', value: 'upload' },
},
{
id: 'fileRef',
title: 'File',
type: 'short-input',
canonicalParamId: 'file',
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:
import { normalizeFileInput } from '@/blocks/utils'
tools: {
config: {
tool: (params) => {
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. Internal tool routes are HTTP boundaries and follow the same contract policy as public routes — define the request/response shape in apps/sim/lib/api/contracts/{service}-tools.ts (or an existing internal-tools.ts / communication-tools.ts aggregate) and validate with canonical helpers from @/lib/api/server. Never write a route-local Zod schema.
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts'
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
export const {service}UploadBodySchema = z.object({
accessToken: z.string(),
file: FileInputSchema.optional().nullable(),
fileContent: z.string().optional().nullable(),
})
export const {service}UploadResponseSchema = z.object({
success: z.boolean(),
output: z.object({ id: z.string(), url: z.string() }).optional(),
error: z.string().optional(),
})
export const {service}UploadContract = defineRouteContract({
method: 'POST',
path: '/api/tools/{service}/upload',
body: {service}UploadBodySchema,
response: { mode: 'json', schema: {service}UploadResponseSchema },
})
export type {Service}UploadBody = z.input<typeof {service}UploadBodySchema>
export type {Service}UploadResponse = z.output<typeof {service}UploadResponseSchema>
import { createLogger } from '@sim/logger'
import { NextResponse, type NextRequest } from 'next/server'
import { {service}UploadContract } from '@/lib/api/contracts/{service}-tools'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { 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')
export const POST = withRouteHandler(async (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 parsed = await parseRequest({service}UploadContract, request, {})
if (!parsed.success) return parsed.response
const data = parsed.data.body
let fileBuffer: Buffer
let fileName: string
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) {
fileBuffer = Buffer.from(data.fileContent, 'base64')
fileName = 'file'
} else {
return NextResponse.json({ success: false, error: 'File required' }, { status: 400 })
}
const response = await fetch('https://api.{service}.com/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${data.accessToken}` },
body: new Uint8Array(fileBuffer),
})
})
4. Update Tool to Use Internal Route
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' },
},
request: {
url: '/api/tools/{service}/upload',
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
import { FileToolProcessor } from '@/executor/utils/file-tool-processor'
transformResponse: async (response, context) => {
const data = await response.json()
const fileProcessor = new FileToolProcessor(context)
const file = await fileProcessor.processFileData({
data: data.content,
mimeType: data.mimeType,
filename: data.filename,
})
return {
success: true,
output: { file },
}
}
In API Route (for complex file handling)
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
{
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:
- Define scopes in
lib/oauth/oauth.ts under OAUTH_PROVIDERS[provider].services[service].scopes
- Add descriptions in
SCOPE_DESCRIPTIONS within lib/oauth/utils.ts for the OAuth modal UI
- Reference in auth.ts using
getCanonicalScopesForProvider(providerId) from @/lib/oauth/utils
- 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.
scopes: getCanonicalScopesForProvider('{service}'),
requiredScopes: getScopesForService('{service}'),
Common Gotchas
- OAuth serviceId must match - The
serviceId in oauth-input must match the OAuth provider configuration
- 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
- Block type is snake_case -
type: 'stripe', not type: 'Stripe'
- Alphabetical ordering - Keep imports and registry entries alphabetically sorted
- Required can be conditional - Use
required: { field: 'op', value: 'create' } instead of always true
- DependsOn clears options - When a dependency changes, selector options are refetched
- Never pass Buffer directly to fetch - Convert to
new Uint8Array(buffer) for TypeScript compatibility
- Always handle legacy file params - Keep hidden
fileContent params for backwards compatibility
- Optional fields use advanced mode - Set
mode: 'advanced' on rarely-used optional fields
- Complex inputs need wandConfig - Timestamps, JSON arrays, and other hard-to-type values should have
wandConfig enabled
- Never hardcode scopes - Use
getScopesForService() in blocks and getCanonicalScopesForProvider() in auth.ts
- Always add scope descriptions - New scopes must have entries in
SCOPE_DESCRIPTIONS within lib/oauth/utils.ts