CtrlK
BlogDocsLog inGet started
Tessl Logo

adding-a-trigger

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.

77

Quality

96%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

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}_trigger table with at minimum these columns (mirrored from kafka/gcp):
    • primary: (workspace_id, path)
    • script_path, is_flow, enabled, mode, permissioned_as, edited_by, email
    • edited_at, error, server_id, last_server_ping
    • error_handler_path, error_handler_args jsonb, retry jsonb
    • trigger-specific fields
  • 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: features enterprise, private if EE, standard deps
  • src/lib.rs: pub use mod_ee::*; behind #[cfg(all(feature = "enterprise", feature = "private"))]
  • src/mod_ee.rs: core types + helpers
  • src/handler_ee.rs: TriggerCrud impl + route handlers
  • src/listener_ee.rs: (only if streaming/pull-based) Listener trait impl

Required 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_fn

Required in handler_ee.rs:

  • #[async_trait] impl TriggerCrud for {Kind}Trigger with:
    • type Trigger = Trigger<{Kind}Config>
    • type TriggerConfigRequest = {Kind}ConfigRequest
    • const ROUTE_PREFIX: &'static str = "/{kind}_triggers";
    • const TABLE_NAME, ADDITIONAL_SELECT_FIELDS
    • get_deployed_object, validate_config, create_trigger, update_trigger, delete_trigger, test_connection
    • additional_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 the TriggerKind enum
  • 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} + /test paths (mirror gcp section)
  • Any additional_routes your handler exposes (resource discovery, etc.)
  • Schemas: {Kind}Trigger, {Kind}TriggerData, {Kind}Mode (if enum), {Kind}DeliveryConfig, helper request/response types
  • Add {kind} to CaptureTriggerKind enum
  • Add {kind}_used: boolean to the UsedTriggers response 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.rsKINDS: [&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.rsvalid_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_domainsTRIGGER_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 enum
  • Three kind 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.

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}TriggerConfig struct (gated by feature flags)
  • TriggerConfig::{Kind} variant
  • set_{kind}_trigger_config function (creates the subscription/equivalent pointing at the capture URL — use your manage_{kind}_subscription helper with trigger_mode=false)
  • Both real + no-op versions behind feature gates
  • 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: .route("/{kind}/{runnable_kind}/{*path}", post({kind}_payload)) inside workspaced_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}" to TRIGGER_TYPES array
  • Add "{kind}_trigger" to getTypeStrFromPath return union
  • Add match case in getTypeStrFromPath's typeEnding === chain
  • Add pushTrigger("{kind}", ...) branch in pushObj

cli/src/commands/trigger/trigger.ts:

  • Import {Kind}Trigger type
  • Add {kind}: {Kind}Trigger to the Trigger type map
  • Add {kind}: wmill.get{Kind}Trigger, update{Kind}Trigger, create{Kind}Trigger to each function map
  • Add {kind}: { ... } template to triggerTemplates
  • Add list{Kind}Triggers call + spread in the list aggregation
  • Update --kind option 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" in getTypeOrder
  • Add "{kind}_trigger" to the delete-suffix regex (~line 3092)
  • Add a case "{kind}_trigger" in the delete switch

cli/src/guidance/skills.tsDO NOT EDIT DIRECTLY. It's auto-generated by system_prompts/generate.py. Instead:

  • Edit 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)
  • Then run python3 system_prompts/generate.py — it regenerates cli/src/guidance/skills.ts with the schema extracted from backend/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?) method
    • isEditor prop, onConfigChange + onCaptureConfigChange callbacks
    • get{Kind}Config() + get{Kind}CaptureConfig() helpers
    • captureConfig = $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.tsrequestBody builders and any trigger-type-specific helpers

10. Frontend — global integration

Easy to miss:

  • frontend/src/lib/components/triggers.ts — add '{kind}' to the TriggerKind union
  • frontend/src/lib/components/triggers/CaptureWrapper.svelte:
    • Import {Kind}Capture
    • Add to isStreamingCapture() array (streaming = pull-style; push-style is typically false)
    • Add {:else if captureType === '{kind}'} branch with the <{Kind}Capture> render
  • frontend/src/lib/components/sidebar/SidebarContent.svelte — import the icon, add the nav entry
  • frontend/src/lib/components/sidebar/OperatorMenu.svelte — add the operator-mode entry
  • frontend/src/routes/(root)/(logged)/+layout.svelte — destructure {kind}_used from /get_used_triggers response, push '{kind}' into usedKinds
  • frontend/src/lib/components/search/GlobalSearchModal.svelte — import icon, add "Go to {Kind} ..." entry
  • frontend/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 template

10.5 AI system prompts (system_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 too
  • system_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)
  • Run python3 system_prompts/generate.py — this rewrites cli/src/guidance/skills.ts and all auto-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 same MethodRouter
  • on: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) 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 0773b5bc85 for 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.sh committed the updated .sqlx/ offline data
  • cargo check passes with your feature flag + with all trigger features
  • npm run check:fast passes
  • 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 push both round-trip the trigger
  • wmill trigger list includes it
  • OpenAPI schemas are complete (no null in generated types)
Repository
windmill-labs/windmill
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.