复制下面这句话,粘贴给 Claude Code、Codex、Cursor 等 AI 编程工具,它会读取安装说明并在你确认后完成安装。
请阅读 https://ai.atlankj.com/install/asset/gh-add-trigger-3b3f07282f74 ,按照其中的说明把「add-trigger」安装到你(当前 AI 工具)中。执行前先告诉我将运行的命令和写入的位置,等我确认。
查看 AI 将读取的安装说明正在读取 GitHub 原文…
内容来自 GitHub 原始文件,由原作者维护。在 GitHub 查看
You are an expert at creating webhook and polling triggers for Sim. You understand the trigger system, the generic buildTriggerSubBlocks helper, polling infrastructure, and how triggers connect to blocks.
If the service docs do not clearly show the webhook payload JSON for an event, you MUST tell the user instead of guessing trigger outputs or formatInput mappings.
formatInput against unverified webhook bodiesIf the payload shape is unknown, do one of these instead:
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
utils.tsThis file contains all service-specific helpers used by triggers.
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' },
},
}
}
Primary trigger — MUST include includeDropdown: true:
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):
export const {service}EventBTrigger: TriggerConfig = {
// Same as above but: id: '{service}_event_b', no includeDropdown
}
apps/sim/triggers/{service}/index.tsexport { {service}EventATrigger } from './event_a'
export { {service}EventBTrigger } from './event_b'
apps/sim/triggers/registry.tsimport { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}'
export const TRIGGER_REGISTRY: TriggerRegistry = {
// ... existing ...
{service}_event_a: {service}EventATrigger,
{service}_event_b: {service}EventBTrigger,
}
apps/sim/blocks/blocks/{service}.ts)Wire triggers into the block so the trigger UI appears and generate-docs.ts discovers them. Two changes are needed:
subBlocks arraytriggers property after outputs with enabled: true and available: [...]import { getTrigger } from '@/triggers'
export const {Service}Block: BlockConfig = {
// ...
subBlocks: [
// Regular tool subBlocks first...
...getTrigger('{service}_event_a').subBlocks,
...getTrigger('{service}_event_b').subBlocks,
],
// ... tools, inputs, outputs ...
triggers: {
enabled: true,
available: ['{service}_event_a', '{service}_event_b'],
},
}
Versioned blocks (V1 + V2): Many integrations have a hidden V1 block and a visible V2 block. Where you add the trigger wiring depends on how V2 inherits from V1:
...V1Block spread (e.g., Google Calendar): Add trigger to V1 — V2 inherits both subBlocks and triggers automatically.subBlocks (e.g., Google Sheets): Add trigger to V2 (the visible block). V1 is hidden and doesn't need it.generate-docs.ts deduplicates by base type (first match wins). If V1 is processed first without triggers, the V2 triggers won't appear in integrations.json. Always verify by checking the output after running the script.
All provider-specific webhook logic lives in a single handler file: apps/sim/lib/webhooks/providers/{service}.ts.
| 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.
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
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 ?? null,
resource: b.data,
},
}
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
return obj.id && obj.type ? `${obj.type}:${obj.id}` : null
},
}
In apps/sim/lib/webhooks/providers/registry.ts:
import { {service}Handler } from '@/lib/webhooks/providers/{service}'
const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
// ... existing (alphabetical) ...
{service}: {service}Handler,
}
There are two sources of truth that MUST be aligned:
outputs — schema defining what fields SHOULD be available (UI tag dropdown)formatInput on the handler — implementation that transforms raw payload into actual dataIf they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover.
Rules for formatInput:
{ input: { ... } } where inner keys match trigger outputs exactly{ input: ..., skip: { message: '...' } } to skip executionnull for missing optional dataIf 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.
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:
createSubscription — orchestration rolls back the DB webhookdeleteSubscription — log non-fatally{ providerConfigUpdates: { externalId } } — orchestration merges into providerConfigapiKey field to build{Service}ExtraFields with password: trueTrigger 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).
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' },
},
}
}
Use polling when the service lacks reliable webhooks (e.g., Google Sheets, Google Drive, Google Calendar, Gmail, RSS, IMAP). Polling triggers do NOT use buildTriggerSubBlocks — they define subBlocks manually.
apps/sim/triggers/{service}/
├── index.ts # Barrel export
└── poller.ts # TriggerConfig with polling: true
apps/sim/lib/webhooks/polling/
└── {service}.ts # PollingProviderHandler implementation
apps/sim/lib/webhooks/polling/{service}.ts)import { pollingIdempotency } from '@/lib/core/idempotency/service'
import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types'
import { markWebhookFailed, markWebhookSuccess, resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils'
import { processPolledWebhookEvent } from '@/lib/webhooks/processor'
export const {service}PollingHandler: PollingProviderHandler = {
provider: '{service}',
label: '{Service}',
async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> {
const { webhookData, workflowData, requestId, logger } = ctx
const webhookId = webhookData.id
try {
// For OAuth services:
const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId)
const config = webhookData.providerConfig as unknown as {Service}WebhookConfig
// First poll: seed state, emit nothing
if (!config.lastCheckedTimestamp) {
await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: new Date().toISOString() }, logger)
await markWebhookSuccess(webhookId, logger)
return 'success'
}
// Fetch changes since last poll, process with idempotency
// ...
await markWebhookSuccess(webhookId, logger)
return 'success'
} catch (error) {
logger.error(`[${requestId}] Error processing {service} webhook ${webhookId}:`, error)
await markWebhookFailed(webhookId, logger)
return 'failure'
}
},
}
Key patterns:
pollingIdempotency.executeWithIdempotency(provider, key, callback) for dedupprocessPolledWebhookEvent(webhookData, workflowData, payload, requestId) to fire the workflowupdateWebhookProviderConfig(webhookId, partialConfig, logger) for read-merge-write on stateapps/sim/triggers/{service}/poller.ts)import { {Service}Icon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
export const {service}PollingTrigger: TriggerConfig = {
id: '{service}_poller',
name: '{Service} Trigger',
provider: '{service}',
description: 'Triggers when ...',
version: '1.0.0',
icon: {Service}Icon,
polling: true, // REQUIRED — routes to polling infrastructure
subBlocks: [
{ id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger' },
// ... service-specific config fields (dropdowns, inputs, switches) ...
{ id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' },
],
outputs: {
// Must match the payload shape from processPolledWebhookEvent
},
}
apps/sim/triggers/constants.ts — add provider to POLLING_PROVIDERS Setapps/sim/lib/webhooks/polling/registry.ts — import handler, add to POLLING_HANDLERSapps/sim/triggers/registry.ts — import trigger config, add to TRIGGER_REGISTRYAdd to helm/sim/values.yaml under the existing polling cron jobs:
{service}WebhookPoll:
enabled: true
name: {service}-webhook-poll
schedule: "*/1 * * * *"
path: "/api/webhooks/poll/{service}"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
Mirror the existing rssWebhookPoll entry.
apps/sim/lib/webhooks/polling/rss.ts + apps/sim/triggers/rss/poller.tsapps/sim/lib/webhooks/polling/gmail.ts + apps/sim/triggers/gmail/poller.tsapps/sim/lib/webhooks/polling/google-drive.tsapps/sim/lib/webhooks/polling/google-calendar.tsselectorKey or options, never a per-block fetcherA sub-block gets its choices from exactly one of two places. There is no third.
selectorKey — every remote list. Use the add-selector skill to add the key to the
browser-safe manifest and attach its provider behavior on the server. All remote selectors execute
through selectors.execute; never add a client provider module or selector-only fetch route.
{ id: 'triggerCredentials', type: 'oauth-input', canonicalParamId: 'oauthCredential', mode: 'trigger' },
{ id: 'labelIds', type: 'dropdown', multiSelect: true,
selectorKey: 'gmail.labels', dependsOn: ['triggerCredentials'], mode: 'trigger' },
{ id: 'manualLabelIds', type: 'short-input', mode: 'trigger-advanced' },
canonicalParamId: 'oauthCredential' on the credential sub-block is the line people forget. The
shared context builder uses trigger mode and projects only active dependsOn values under their
canonical ids. Exact {{KEY}} environment references remain unresolved until the authorized server
executor. The builder does not infer a credential from type: 'oauth-input'; only the legacy ids
credential / botCredential / customBotCredential / manualBotCredential are aliased. Give the
field canonicalParamId: 'oauthCredential', or declare a manifest sourceFields alias when a
legacy source id must be retained.
options — everything else. A static array, or a pure function of the block's own values for a list that narrows to a sibling's selection. No I/O.
options: (params) => {
const model = params?.values.model
return typeof model === 'string' ? effortsFor(model) : DEFAULT_EFFORTS
}
Never fetch inside options, and never reach into the stores from a block definition. A fetcher that resolves its credential with readSubBlockValue(blockId, ...) only works on the canvas — every surface that is not the editor gets an empty list. fetchOptions/fetchOptionById were removed for exactly this reason.
Two rules the checks enforce:
dependsOn a credential / knowledge-base / table selector must be reconfigurable at fork-sync time — a selectorKey, a canonical pair whose basic member is a selector, or a short-input/long-input. bun run check:fork-dependent-coverage fails otherwise, because a fork sync clears those fields on every push and an unofferable one can never be set anywhere that sticks.Webhook and polling routes are legitimate external ingress boundaries. They must not call this
Sim app's own API routes to reuse provider or business logic. Extract the shared provider operation
or authorized application use case and call it directly from the trigger handler and any other
server adapter. HTTP is reserved for an actual cross-process/capability boundary. Tool work uses a
registered InternalToolConfig.operation; a directExecution property fails
bun run check:tool-request-boundary.
utils.ts with options, instructions, extra fields, and output buildersincludeDropdown: true; secondary triggers do NOTbuildTriggerSubBlocks helperindex.ts barrel exporttriggers/registry.ts → TRIGGER_REGISTRYtriggers.enabled: true and lists all trigger IDs in triggers.available...getTrigger('id').subBlocksapps/sim/lib/webhooks/providers/{service}.tsproviders/registry.ts (alphabetical)formatInput output keys match trigger outputs exactlyawait import() for trigger utilscreateSubscription and deleteSubscription on the handlerroute.ts, provider-subscriptions.ts, or deploy.tspassword: truePollingProviderHandler at lib/webhooks/polling/{service}.tspolling: true and defines subBlocks manually (no buildTriggerSubBlocks)POLLING_PROVIDERS, polling registryPOLLING_PROVIDERS in triggers/constants.tsPOLLING_HANDLERS in lib/webhooks/polling/registry.tshelm/sim/values.yamloutputs schemabun run type-check passesoutputs keysbun run scripts/generate-docs.ts and committed the refreshed pages — trigger sections
render into the owning integration's docs page, and CI's bun run docs:check fails on stale
pages