Checklist for adding a new TriggerCrud-based trigger type to Windmill (Azure, GCP, Kafka, etc.). Use when wiring a new trigger kind across backend, frontend, CL
复制下面这句话,粘贴给 Claude Code、Codex、Cursor 等 AI 编程工具,它会读取安装说明并在你确认后完成安装。
请阅读 https://ai.atlankj.com/install/asset/gh-adding-a-trigger-68213c4930c2 ,按照其中的说明把「adding-a-trigger」安装到你(当前 AI 工具)中。执行前先告诉我将运行的命令和写入的位置,等我确认。
查看 AI 将读取的安装说明正在读取 GitHub 原文…
内容来自 GitHub 原始文件,由原作者维护。在 GitHub 查看
Use this skill when adding a trigger kind that implements TriggerCrud (Kafka, GCP, Azure, MQTT, SQS, NATS, Postgres, Email…). For native triggers (Nextcloud, Google Drive — things wired through windmill-native-triggers), use the native-trigger skill instead.
The goal of this doc is to enumerate every file that needs to change. Missing any one of them leads to silent regressions: sync drops the trigger, capture button does nothing, workspace forks lose it, sidebar counters undercount. Follow the checklist top-to-bottom — each section is independent enough to be validated on its own.
Throughout this doc, substitute {kind} for the new trigger kind (azure, kafka, …), {Kind} for PascalCase (Azure, Kafka), {KIND} for SCREAMING (AZURE, KAFKA).
gcp_trigger / GcpTrigger.azure_trigger / AzureTrigger.Create a migration: cargo sqlx migrate add -r add_{kind}_trigger from backend/. Never write timestamps manually.
The up.sql usually defines:
AZURE_MODE) if the trigger has sub-kinds{kind}_trigger table with at minimum these columns (mirrored from kafka/gcp):
(workspace_id, path)script_path, is_flow, enabled, mode, permissioned_as, edited_by, emailedited_at, error, server_id, last_server_pingerror_handler_path, error_handler_args jsonb, retry jsonbworkspacesee_own, see_member, see_folder_extra_perms_user_*, see_extra_perms_user_*, see_extra_perms_groups_*), copied from an existing trigger tableRLS: wrap every session GUC read in a scalar sub-select. Write the session
reads as (select current_setting('session.user')),
= any((select regexp_split_to_array(current_setting('session.groups'), ','))::text[]),
?| (select regexp_split_to_array(current_setting('session.pgroups'), ','))::text[],
? (select concat('u/', current_setting('session.user'))), etc. — not the bare
current_setting(...). The GUCs are set with SET LOCAL, so the sub-select
hoists them to a one-time InitPlan instead of re-evaluating per scanned row.
Put the ::text[] cast outside the sub-select for the array cases: in an
= any (...) context, casting inside — = any((select ...::text[])) — makes
Postgres parse the operand as a row-returning subquery and fails at CREATE with
operator does not exist: text = text[]. The outside cast keeps it in
array-operand form. See migration 20260714230440_wrap_session_gucs_in_rls_policies
for the canonical wrapped forms.
Down migration drops the table and any enum types.
windmill-trigger-{kind})Create a new crate under backend/windmill-trigger-{kind}/ with:
Cargo.toml: features enterprise, private if EE, standard depssrc/lib.rs: pub use mod_ee::*; behind #[cfg(all(feature = "enterprise", feature = "private"))]src/mod_ee.rs: core types + helperssrc/handler_ee.rs: TriggerCrud impl + route handlerssrc/listener_ee.rs: (only if streaming/pull-based) Listener trait implRequired in mod_ee.rs:
{Kind}Config struct (persisted shape, FromRow){Kind}ConfigRequest struct (what API receives — usually similar to Config but with validation fields){Kind}Trigger unit struct (implements the traits)impl TriggerJobArgs for {Kind}Trigger — sets TRIGGER_KIND, Payload, v1_payload_fnRequired in handler_ee.rs:
#[async_trait] impl TriggerCrud for {Kind}Trigger with:
type Trigger = Trigger<{Kind}Config>type TriggerConfigRequest = {Kind}ConfigRequestconst ROUTE_PREFIX: &'static str = "/{kind}_triggers";const TABLE_NAME, ADDITIONAL_SELECT_FIELDSget_deployed_object, validate_config, create_trigger, update_trigger, delete_trigger, test_connectionadditional_routes (optional — mount extra endpoints for things like ARM resource listing, topic discovery)Register the crate in backend/Cargo.toml as a workspace member and as a dep of windmill-api behind the feature flag.
windmill-api (feature-gated everywhere)backend/windmill-api/src/triggers/handler.rs — mount the trigger crate:
#[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))]
{
use crate::triggers::{kind}::{Kind}Trigger;
router = router.nest({Kind}Trigger::ROUTE_PREFIX, complete_trigger_routes({Kind}Trigger));
}
backend/windmill-api/src/triggers/{kind}/mod.rs — re-export the crate:
pub use windmill_trigger_{kind}::*;
backend/windmill-api/src/lib.rs — if the trigger receives inbound pushes, add a webhook route:
.nest("/{kind}/w/{workspace_id}", {
#[cfg(all(feature = "enterprise", feature = "{kind}_trigger", feature = "private"))]
{ triggers::{kind}::handler_oss::{kind}_push_route_handler() }
#[cfg(not(...))]
{ Router::new() }
})
TriggerKind enum (backend/windmill-types/src/triggers.rs)Already has slots for most triggers but verify your variant exists:
{Kind} to the TriggerKind enumto_key()from_strJobTriggerKind (if jobs need kind tagging)backend/windmill-api/openapi.yaml)This file is huge and the single most-forgotten place. Add:
/w/{workspace}/{kind}_triggers/create + /update/{path} + /delete/{path} + /get/{path} + /list + /exists/{path} + /setmode/{path} + /test paths (mirror gcp section)additional_routes your handler exposes (resource discovery, etc.){Kind}Trigger, {Kind}TriggerData, {Kind}Mode (if enum), {Kind}DeliveryConfig, helper request/response types{kind} to CaptureTriggerKind enum{kind}_used: boolean to the UsedTriggers response schemaRegenerate frontend client: npm run generate-backend-client from frontend/.
UsedTriggers + workspace exportbackend/windmill-api-workspaces/src/workspaces.rs — add {kind}_used: bool to the UsedTriggers struct and add an EXISTS(SELECT 1 FROM {kind}_trigger …) to the get_used_triggers query.
backend/windmill-api/src/workspaces_export.rs — add export block mirroring gcp's (export lists all triggers, serializes them to YAML/JSON). The block re-uses the trigger_ignore_keys variable so the new kind automatically participates in fork-export stripping (mode field is omitted when the source workspace is a fork — keeps fork→parent merges from flipping the parent's enabled state).
Fork cloning (clone_triggers_and_schedules in workspaces.rs) — add an INSERT INTO {kind}_trigger ... SELECT ... block that copies all rows from the parent workspace, forcing mode = 'disabled'::TRIGGER_MODE. Always runs at fork creation; forgetting this means users can't carry {kind} triggers into their forks.
Several files keep hardcoded arrays of trigger kind strings. Miss one and ACL checks / user offboarding / trash drop your kind:
backend/windmill-api-groups/src/granular_acls.rs — KINDS: [&str; N]. Increment N (the compile error is cryptic otherwise). Controls which kinds accept granular ACL operations.backend/windmill-api-users/src/users.rs (extra_perms_tables) — which tables get extra_perms entries cleaned when a user is deleted.backend/windmill-api/src/offboarding.rs — three separate arrays (enumeration, fork-copy, and delete paths). All three need the new kind.backend/windmill-api/src/trash.rs — valid_tables for the trash / restore API.backend/windmill-git-sync/src/lib.rs — add a test assertion for DeployedObject::{Kind}Trigger.get_kind() == "{kind}_trigger" (the get_kind match arm itself lives in the enum impl — already required by the Rust compiler).backend/windmill-api-auth/src/scopes.rs — add the {Kind}Triggers variant to ScopeDomain enum + as_str match + from_str match. Required for the OAuth/token system to recognise {kind}_triggers:read|write scopes.backend/windmill-api/src/token.rs (build_trigger_scope_domains → TRIGGER_DOMAINS) — add ("{kind}_triggers", "{Kind display name}") so the CreateToken UI's scope selector surfaces the read / write checkboxes.OpenAPI enums to extend (do NOT forget — generated client will allow it but server rejects as 400):
CaptureTriggerKind enumkind enums under /w/{workspace}/acls/{get,add,remove}/{kind}/{path} (yes, same list repeated three times)After editing any of these, run a full cargo check with your feature flag + gcp_trigger + other core flags — the KINDS: [&str; N] length mismatch only surfaces when the crate compiles.
backend/windmill-api/src/capture.rs)If the trigger supports push delivery, it also needs a capture endpoint so users can test it:
{Kind}TriggerConfig struct (gated by feature flags)TriggerConfig::{Kind} variantset_{kind}_trigger_config function (creates the subscription/equivalent pointing at the capture URL — use your manage_{kind}_subscription helper with trigger_mode=false)TriggerKind::{Kind} => set_{kind}_trigger_config(...) arm in set_config{kind}_payload async handler — validates auth (if any), processes payload, calls insert_capture_payload.route("/{kind}/{runnable_kind}/{*path}", post({kind}_payload)) inside workspaced_unauthed_service — and expand the surrounding #[cfg(any(...))] to include your feature flagcli/) — easy to miss, breaks sync silentlyCheck all of these:
cli/src/types.ts:
"{kind}" to TRIGGER_TYPES array"{kind}_trigger" to getTypeStrFromPath return uniongetTypeStrFromPath's typeEnding === chainpushTrigger("{kind}", ...) branch in pushObjcli/src/commands/trigger/trigger.ts:
{Kind}Trigger type{kind}: {Kind}Trigger to the Trigger type map{kind}: wmill.get{Kind}Trigger, update{Kind}Trigger, create{Kind}Trigger to each function map{kind}: { ... } template to triggerTemplateslist{Kind}Triggers call + spread in the list aggregation--kind option descriptions to mention the new kindcli/src/commands/sync/sync.ts:
path.endsWith(".{kind}_trigger" + ext) in the file-type filtertyp == "{kind}_trigger" in getTypeOrder"{kind}_trigger" to the delete-suffix regex (~line 3092)case "{kind}_trigger" in the delete switchcli/src/guidance/skills.ts — DO NOT EDIT DIRECTLY. It's auto-generated by system_prompts/generate.py. Instead:
system_prompts/utils.py → append ('{Kind}Trigger', '{kind}_trigger') to the SCHEMA_MAPPINGS['triggers'] list (this is the master list — the one in generate.py is duplicated and utils.py wins)python3 system_prompts/generate.py — it regenerates cli/src/guidance/skills.ts with the schema extracted from backend/windmill-api/openapi.yamlUnder frontend/src/lib/components/triggers/{kind}/:
{Kind}TriggerPanel.svelte — the tile shown in the triggers listing{Kind}TriggerEditor.svelte — outer drawer wrapper{Kind}TriggerEditorInner.svelte — state + business logic; must expose:
openEdit(path, isFlow, defaultValues?) methodisEditor prop, onConfigChange + onCaptureConfigChange callbacksget{Kind}Config() + get{Kind}CaptureConfig() helperscaptureConfig = $derived.by(untrack(() => isEditor) ? get{Kind}CaptureConfig : () => ({}))$effect(() => { const args = [captureConfig, isValid] as const; untrack(() => onCaptureConfigChange?.(...args)) }){Kind}TriggerEditorConfigSection.svelte — form fields; use design-system components (TextInput, Select, Toggle, ToggleButtonGroup), never raw <input>{Kind}Capture.svelte — capture panel; wraps CaptureSection with captureType="{kind}"utils.ts — requestBody builders and any trigger-type-specific helpersEasy to miss:
frontend/src/lib/components/triggers.ts — add '{kind}' to the TriggerKind unionfrontend/src/lib/components/triggers/CaptureWrapper.svelte:
{Kind}CaptureisStreamingCapture() array (streaming = pull-style; push-style is typically false){:else if captureType === '{kind}'} branch with the <{Kind}Capture> renderfrontend/src/lib/components/sidebar/SidebarContent.svelte — import the icon, add the nav entryfrontend/src/lib/components/sidebar/OperatorMenu.svelte — add the operator-mode entryfrontend/src/routes/(root)/(logged)/+layout.svelte — destructure {kind}_used from /get_used_triggers response, push '{kind}' into usedKindsfrontend/src/lib/components/search/GlobalSearchModal.svelte — import icon, add "Go to {Kind} ..." entryfrontend/src/lib/components/offboarding-utils.ts — add mappings {kind}_trigger: '{kind}_triggers' and {kind}_trigger: '{kind} trigger'frontend/src/lib/components/icons/{Kind}Icon.svelte — single-path SVG, fill={color ?? 'currentColor'}, size prop default 16 (match existing icons — don't hardcode colors, don't use width/height props)frontend/src/routes/(root)/(logged)/{kind}_triggers/+page.svelte — listing page (mirror gcp_triggers/+page.svelte for push+pull, kafka_triggers for pure streaming)frontend/src/lib/components/CompareWorkspaces.svelte — workspace fork / compare tool. Needs: service import, editor import, {kind}Editor $state, case '{kind}' in openTriggerDetails(), entry in triggerServices object (list/delete/normalize), and <{Kind}TriggerEditor bind:this={{kind}Editor} /> in the templatesystem_prompts/)system_prompts/utils.py — append ('{Kind}Trigger', '{kind}_trigger') to SCHEMA_MAPPINGS['triggers'] (master list used by code generation + CLI skills)system_prompts/generate.py — also has a duplicated schema_types list (~line 903) for the AI triggers skill content. Add ('{Kind}Trigger', '{kind}_trigger') there toosystem_prompts/generate.py schema_names (~line 1192) — add '{Kind}Trigger' (add 'New{Kind}Trigger' only if the OpenAPI declares one; GCP and Azure don't)python3 system_prompts/generate.py — this rewrites cli/src/guidance/skills.ts and all auto-generated/ docs. Commit the regenerated filesRun all of these before declaring done:
# Backend
cd backend
cargo check --features enterprise,{kind}_trigger,private # minimal
cargo check --features enterprise,azure_trigger,private,gcp_trigger,http_trigger,mqtt_trigger,postgres_trigger,sqs_trigger,kafka,nats,smtp,websocket # full
# SQLx offline data (never run `cargo sqlx prepare` directly — use the wrapper)
./update_sqlx.sh
# Frontend
cd frontend
npm run generate-backend-client
npm run check:fast
Smoke test in the UI: create a trigger, save, check it appears in sidebar + search, delete, re-create via CLI wmill sync.
workspaced_unauthed_service() — the surrounding #[cfg(any(...))] expression must include your feature flag, not just the inner #[cfg] on the route.route(path, ...).route(path, ...) with same path and different methods — older axum replaced; use .route(path, post(h1).options(h2)) to chain methods on the same MethodRouteron:event directives — legacy Svelte 4, no-op in runes mode. Use callback props (onSelected, onConfigChange)$bindable(default_value) on optional props — banned by project CLAUDE.md. Use $bindable() + $derived(prop ?? default) instead0773b5bc85 for a historical fix)If the trigger is enterprise-only, the code lives in windmill-ee-private__worktrees/.../windmill-trigger-{kind}/src/*_ee.rs and is symlinked into the OSS tree. The windmill-ee-private__worktrees/ directory holds the real files; changes propagate via symlinks. See docs/enterprise.md for the workflow.
./update_sqlx.sh committed the updated .sqlx/ offline datacargo check passes with your feature flag + with all trigger featuresnpm run check:fast passescurrentColor)/get_used_triggers → sidebar pulsewmill sync pull + wmill sync push both round-trip the triggerwmill trigger list includes itnull in generated types)