
Adding a New Trigger Type
FreeStreamline the integration of new trigger types in Windmill.
Free · Opens the source repo
What Adding a New Trigger Type does
The Adding a New Trigger Type skill is designed for developers working with Windmill who need to implement new trigger types based on the TriggerCrud interface. This skill provides a comprehensive checklist to ensure that all necessary components are updated correctly when adding a new trigger, such as those for Azure, GCP, Kafka, and more. By following this guide, developers can avoid common pitfalls that lead to silent regressions, ensuring that triggers are properly wired across the backend, frontend, CLI, and capture infrastructure.
The skill outlines a systematic approach to modifying various parts of the Windmill codebase. It starts with database migrations, detailing the specific SQL commands and structures required to create new trigger tables and manage permissions effectively. It emphasizes the importance of adhering to established patterns to maintain consistency and reliability in the application. Each section of the checklist is designed to be validated independently, allowing for thorough testing at every stage of the integration process.
Additionally, the skill covers the creation of backend crates and the necessary modifications to the Windmill API, ensuring that new triggers are fully functional and accessible. This includes defining the required structs and implementing the TriggerCrud interface, which is crucial for handling trigger operations such as creation, updating, and deletion. By following the provided guidelines, developers can ensure that their new trigger types are integrated seamlessly into the Windmill ecosystem.
This skill is particularly useful for backend developers and system integrators who are tasked with expanding Windmill's capabilities by adding new trigger types. It provides a clear and structured approach to what can be a complex process, minimizing the risk of errors and enhancing the overall robustness of the system.
When to use it
Use this skill when you need to add a new trigger type that implements TriggerCrud in Windmill.
When not to use it
This skill is not suitable for adding native triggers, which should utilize the native-trigger skill instead.
What you can build with it
Integrating a New Kafka Trigger
Use this skill to add a Kafka trigger type to Windmill, following the checklist to ensure all components are correctly updated.
Updating GCP Trigger Functionality
When enhancing existing GCP trigger capabilities, this skill helps ensure all necessary changes are made to avoid regressions.
Adding Azure Trigger Support
Utilize this skill to implement a new Azure trigger type, ensuring compliance with Windmill's architecture and standards.
How to install Adding a New Trigger Type
View source1. Install with the skills CLI
npx skills add windmill-labs/windmill/adding-a-trigger --agent claude-code2. Or install it manually
Download the skill folder and drop it into ~/.claude/skills/ for all projects, or .claude/skills/ to scope it to one repo. Restart Claude Code so it picks up the new skill.
Anthropic's agentic coding CLI, and the reference implementation of Agent Skills. Drop a skill folder into ~/.claude/skills and Claude Code loads it automatically whenever a task matches the skill's description. Claude Code docs
Inside SKILL.md
Written by windmill-labsSkill: 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)
Frequently asked questions about Adding a New Trigger Type
Similar skills
WinMD API Search
Easily find and explore Windows desktop APIs.
WebMCPify
Transform any web app into an agent-ready platform.
Phoenix Tracing
Instrument LLM applications with OpenInference tracing.
Foundry Hosted Agent CopilotKit
Guidance for developing agentic web apps on Azure.
Power Automate Foundation
Connect AI agents to Power Automate seamlessly.
Power Automate Flow Builder
Efficiently build and deploy Power Automate flows programmatically.
