adding-a-trigger
GitHub提供在Windmill中添加新Trigger类型的完整检查清单,涵盖后端、前端、CLI及基础设施的代码修改与数据库迁移步骤,防止静默回归。
Trigger Scenarios
Install
npx skills add windmill-labs/windmill --skill adding-a-trigger -g -y
SKILL.md
Frontmatter
{
"name": "adding-a-trigger",
"description": "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, CLI, and capture infrastructure."
}
Skill: Adding a New Trigger Type
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).
Reference implementations
- GCP — closest analogue to Azure. Has push + pull, OIDC auth, ARM-like resource paths, capture handler. Grep for
gcp_trigger/GcpTrigger. - Kafka — simpler (pull-only, streaming). Good for trivial integrations.
- Azure — most recently added (2026). Shared-secret push auth, Event Grid namespaces + basic topics, ARM resource discovery, Namespace-pull data-plane. Grep for
azure_trigger/AzureTrigger.
1. Database migration
Create a migration: cargo sqlx migrate add -r add_{kind}_trigger from backend/. Never write timestamps manually.
The up.sql usually defines:
- An optional enum type (e.g.
AZURE_MODE) if the trigger has sub-kinds - The
{kind}_triggertable with at minimum these columns (mirrored from kafka/gcp):- primary:
(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 jsonb- trigger-specific fields
- primary:
- Indexes on foreign keys + any frequently-filtered columns
- Foreign key to
workspace - The RLS policies (
see_own,see_member,see_folder_extra_perms_user_*,see_extra_perms_user_*,see_extra_perms_groups_*), copied from an existing trigger table
RLS: 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.
2. Backend crate (windmill-trigger-{kind})
Create a new crate under backend/windmill-trigger-{kind}/ with:
Cargo.toml: featuresenterprise,privateif 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:TriggerCrudimpl + route handlerssrc/listener_ee.rs: (only if streaming/pull-based)Listenertrait impl
Required in mod_ee.rs:
{Kind}Configstruct (persisted shape,FromRow){Kind}ConfigRequeststruct (what API receives — usually similar to Config but with validation fields){Kind}Triggerunit struct (implements the traits)impl TriggerJobArgs for {Kind}Trigger— setsTRIGGER_KIND,Payload,v1_payload_fn
Required in handler_ee.rs:
#[async_trait] impl TriggerCrud for {Kind}Triggerwith: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.
3. Wire into 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() }
})
4. TriggerKind enum (backend/windmill-types/src/triggers.rs)
Already has slots for most triggers but verify your variant exists:
- Add
{Kind}to theTriggerKindenum - Add match arm in
to_key() - Add match arm in
from_str - Add match arm in
JobTriggerKind(if jobs need kind tagging)
5. OpenAPI (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}+/testpaths (mirror gcp section)- Any
additional_routesyour handler exposes (resource discovery, etc.) - Schemas:
{Kind}Trigger,{Kind}TriggerData,{Kind}Mode(if enum),{Kind}DeliveryConfig, helper request/response types - Add
{kind}toCaptureTriggerKindenum - Add
{kind}_used: booleanto theUsedTriggersresponse schema
Regenerate frontend client: npm run generate-backend-client from frontend/.
6. UsedTriggers + workspace export
backend/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.
6.5 Hardcoded trigger-kind arrays (silent-failure hotspots)
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 getextra_permsentries 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_tablesfor the trash / restore API.backend/windmill-git-sync/src/lib.rs— add a test assertion forDeployedObject::{Kind}Trigger.get_kind() == "{kind}_trigger"(theget_kindmatch arm itself lives in the enum impl — already required by the Rust compiler).backend/windmill-api-auth/src/scopes.rs— add the{Kind}Triggersvariant toScopeDomainenum +as_strmatch +from_strmatch. Required for the OAuth/token system to recognise{kind}_triggers:read|writescopes.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 theread/writecheckboxes.
OpenAPI enums to extend (do NOT forget — generated client will allow it but server rejects as 400):
CaptureTriggerKindenum- Three
kindenums 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.
7. Capture infrastructure (backend/windmill-api/src/capture.rs)
If the trigger supports push delivery, it also needs a capture endpoint so users can test it:
{Kind}TriggerConfigstruct (gated by feature flags)TriggerConfig::{Kind}variantset_{kind}_trigger_configfunction (creates the subscription/equivalent pointing at the capture URL — use yourmanage_{kind}_subscriptionhelper withtrigger_mode=false)- Both real + no-op versions behind feature gates
TriggerKind::{Kind} => set_{kind}_trigger_config(...)arm inset_config{kind}_payloadasync handler — validates auth (if any), processes payload, callsinsert_capture_payload- Route:
.route("/{kind}/{runnable_kind}/{*path}", post({kind}_payload))insideworkspaced_unauthed_service— and expand the surrounding#[cfg(any(...))]to include your feature flag
8. CLI (cli/) — easy to miss, breaks sync silently
Check all of these:
cli/src/types.ts:
- Add
"{kind}"toTRIGGER_TYPESarray - Add
"{kind}_trigger"togetTypeStrFromPathreturn union - Add match case in
getTypeStrFromPath'stypeEnding ===chain - Add
pushTrigger("{kind}", ...)branch inpushObj
cli/src/commands/trigger/trigger.ts:
- Import
{Kind}Triggertype - Add
{kind}: {Kind}Triggerto theTriggertype map - Add
{kind}: wmill.get{Kind}Trigger,update{Kind}Trigger,create{Kind}Triggerto each function map - Add
{kind}: { ... }template totriggerTemplates - Add
list{Kind}Triggerscall + spread in thelistaggregation - Update
--kindoption descriptions to mention the new kind
cli/src/commands/sync/sync.ts:
- Add
path.endsWith(".{kind}_trigger" + ext)in the file-type filter - Add
typ == "{kind}_trigger"ingetTypeOrder - Add
"{kind}_trigger"to the delete-suffix regex (~line 3092) - Add a
case "{kind}_trigger"in the delete switch
cli/src/guidance/skills.ts — DO NOT EDIT DIRECTLY. It's auto-generated by system_prompts/generate.py. Instead:
- Edit
system_prompts/utils.py→ append('{Kind}Trigger', '{kind}_trigger')to theSCHEMA_MAPPINGS['triggers']list (this is the master list — the one ingenerate.pyis duplicated andutils.pywins) - Then run
python3 system_prompts/generate.py— it regeneratescli/src/guidance/skills.tswith the schema extracted frombackend/windmill-api/openapi.yaml - Commit the regenerated file
9. Frontend — editor + drawer
Under 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?)methodisEditorprop,onConfigChange+onCaptureConfigChangecallbacksget{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; wrapsCaptureSectionwithcaptureType="{kind}"utils.ts—requestBodybuilders and any trigger-type-specific helpers
10. Frontend — global integration
Easy to miss:
frontend/src/lib/components/triggers.ts— add'{kind}'to theTriggerKindunionfrontend/src/lib/components/triggers/CaptureWrapper.svelte:- Import
{Kind}Capture - Add to
isStreamingCapture()array (streaming = pull-style; push-style is typicallyfalse) - Add
{:else if captureType === '{kind}'}branch with the<{Kind}Capture>render
- Import
frontend/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}_usedfrom/get_used_triggersresponse, push'{kind}'intousedKindsfrontend/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'},sizeprop default 16 (match existing icons — don't hardcode colors, don't usewidth/heightprops)frontend/src/routes/(root)/(logged)/{kind}_triggers/+page.svelte— listing page (mirrorgcp_triggers/+page.sveltefor push+pull,kafka_triggersfor pure streaming)frontend/src/lib/components/CompareWorkspaces.svelte— workspace fork / compare tool. Needs: service import, editor import,{kind}Editor$state,case '{kind}'inopenTriggerDetails(), entry intriggerServicesobject (list/delete/normalize), and<{Kind}TriggerEditor bind:this={{kind}Editor} />in the template
10.5 AI system prompts (system_prompts/)
system_prompts/utils.py— append('{Kind}Trigger', '{kind}_trigger')toSCHEMA_MAPPINGS['triggers'](master list used by code generation + CLI skills)system_prompts/generate.py— also has a duplicatedschema_typeslist (~line 903) for the AItriggersskill content. Add('{Kind}Trigger', '{kind}_trigger')there toosystem_prompts/generate.pyschema_names(~line 1192) — add'{Kind}Trigger'(add'New{Kind}Trigger'only if the OpenAPI declares one; GCP and Azure don't)- Run
python3 system_prompts/generate.py— this rewritescli/src/guidance/skills.tsand allauto-generated/docs. Commit the regenerated files
11. Validation
Run 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.
12. Common pitfalls
- Forgetting feature gates in
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 sameMethodRouteron:eventdirectives — 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)instead- CORS layer intercepting OPTIONS — tower-http CorsLayer short-circuits OPTIONS before reaching your handler. For server-to-server webhook endpoints, drop the CORS layer entirely (CORS is browser-only)
- DeliveryAttributeMappings / custom headers for auth — prefer HMAC or sha256-hashed shared secrets over opaque JWTs when the provider doesn't support signed tokens natively. Store only the hash; regenerate secret on every save
- ARM / API resource-listing cascades — if the trigger's resource type is deep (Azure: subscription → RG → namespace → topic), offer dropdowns in the UI populated from the provider's APIs using the user's credential resource
- Clearing stale selections on dependency change — when a dropdown's underlying data reloads (e.g., user changes SP or edition), clear selections that no longer match the new list
- Workspace-scoped tag compatibility — if the trigger has tags, verify forked workspaces handle them (see commit
0773b5bc85for a historical fix)
13. EE file split
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.
14. Final checklist before PR
- Migration up/down tested (revert + re-apply)
-
./update_sqlx.shcommitted the updated.sqlx/offline data -
cargo checkpasses with your feature flag + with all trigger features -
npm run check:fastpasses - Trigger visible in sidebar with correct icon weight (not oversized/colored — use
currentColor) - Create, edit, delete flow all work in the UI
- Capture button works (if push-capable)
- Trigger appears in
/get_used_triggers→ sidebar pulse -
wmill sync pull+wmill sync pushboth round-trip the trigger -
wmill trigger listincludes it - OpenAPI schemas are complete (no
nullin generated types)
Version History
- 574775d Current 2026-08-20 17:31


