* ci: make local-review a single-source-of-truth cross-agent skill The repo already had parallel skills directories (.agents/skills/ and .claude/skills/) drifting between agents. Consolidate local-review onto one canonical file in .agents/ and symlink the .claude/ entry to it so Claude Code and Pi share the exact same SKILL.md (Anthropic's Skills format is supported by both, only the discovery directory differs). The canonical SKILL.md now points reviewers at .github/review-prompt-shared.md as the policy source — same shared prompt the GitHub auto-review workflows already use — so local reviews and CI reviews stay in lockstep. Codex CLI doesn't support repo-level slash commands (its prompts live in ~/.codex/prompts/). For Codex parity, ship scripts/local-review.sh which pipes the SKILL + shared policy into 'codex exec' (or 'pi -p' as a uniform entry point). Update AGENTS.md to document the three invocation paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: make all skills cross-agent — single source in .agents/, symlink .claude/ Turn every skill into a single canonical file under .agents/skills/ and a symlink under .claude/skills/. Editing any one SKILL.md now updates all three CLIs (Claude Code reads .claude/, Codex and Pi auto-discover .agents/). Per-skill resolution: - local-review: already symlinked (prior PR #9037) - rust-backend, svelte-frontend: identical content → symlink, no edit - refine: only differed in user_invocable frontmatter → add to canonical - native-trigger: .claude/ had a newer Step 17 (sidebar visibility) missing from .agents/ → use Claude content as canonical - commit: .claude/ embedded a Claude-specific Co-Authored-By trailer the harness already injects automatically → drop from canonical, use agent-neutral .agents/ version - pr: generalize "Run /local-review" to "Invoke the local-review skill (/local-review in Claude Code, $local-review in Codex, pi --skill local-review in Pi)" and drop the Claude-specific "Generated with Claude Code" attribution from the PR body template — the harness that invoked the skill can add its own trailer if desired - adding-a-trigger: was only in .claude/ → move to .agents/ canonical - update-sqlx: was only in .agents/ → add .claude/ symlink Also drop scripts/local-review.sh — wrapper is redundant now that all three CLIs natively discover the skill from their respective directories. Update AGENTS.md accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 KiB
name, description
| name | description |
|---|---|
| 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. |
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
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 datacargo checkpasses with your feature flag + with all trigger featuresnpm 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 triggerwmill trigger listincludes it- OpenAPI schemas are complete (no
nullin generated types)