Guidance for adding native trigger services to Windmill. Use when implementing or modifying native trigger integrations across the backend and frontend.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Guidance for adding native trigger services to Windmill. Use when implementing or modifying native trigger integrations across the backend and frontend.
Skill: Adding Native Trigger Services
This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications.
Architecture Overview
The native trigger system consists of:
Database Layer - PostgreSQL tables and enum types
Backend Rust Implementation - Core trait, handlers, and service modules in the windmill-native-triggers crate
Frontend Svelte Components - Configuration forms and UI components
The native trigger code lives in the windmill-native-triggers crate (backend/windmill-native-triggers/). The windmill-api crate re-exports everything via a shim:
update() returns serde_json::Value - the resolved service_config to store. Each service is responsible for building the final config.
maintain_triggers() - periodic background maintenance. Each service implements its own strategy (Nextcloud: reconcile with external state; Google: renew expiring channels).
No list_all() in the trait - services that need it (Nextcloud) implement it privately; services that don't (Google) use different maintenance strategies.
No get_external_id_from_trigger_data() or extract_service_config_from_trigger_data() - removed in favor of the maintain_triggers pattern.
Create Lifecycle: Two Paths
The create_native_trigger handler in handler.rs supports two creation flows, controlled by service_config_from_create_response():
Path A: Short (Google pattern) - service_config_from_create_response() returns Some(config):
create() registers on external service
external_id_and_metadata_from_response() extracts the ID
service_config_from_create_response() builds the config directly from input data + response metadata
Stores trigger in DB -- done, no extra round-trip
Use this when the external_id is known before the create call (e.g., Google generates the channel_id as a UUID upfront and includes it in the webhook URL).
Path B: Long (Nextcloud pattern) - service_config_from_create_response() returns None (default):
create() registers on external service (webhook URL has no external_id yet)
external_id_and_metadata_from_response() extracts the ID
update() is called to fix the webhook URL with the now-known external_id
update() returns the resolved service_config
Stores trigger in DB
Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation.
OAuth Token Storage (Three-Table Pattern)
OAuth tokens are stored across three tables, NOT in workspace_integrations.oauth_data directly:
Table
What's Stored
workspace_integrations
oauth_data JSON with base_url, client_id, client_secret, instance_shared flag; resource_path pointing to the variable
variable
Encrypted access_token (at the path stored in resource_path), linked to account via account column
The decrypt_oauth_data() function in lib.rs assembles these into a unified struct:
pubstructOAuthConfig {
pub base_url: String,
pub access_token: String, // decrypted from variablepub refresh_token: Option<String>, // from account tablepub client_id: String, // from oauth_data or instance settingspub client_secret: String, // from oauth_data or instance settings
}
Instance-level sharing: when oauth_data.instance_shared == true, client_id and client_secret are read from global settings instead of workspace_integrations.
URL Resolution
The resolve_endpoint() helper handles both absolute and relative OAuth URLs:
ServiceName is the central registry enum. Each variant must implement these match arms:
Method
Purpose
as_str()
Lowercase identifier (e.g., "google")
as_trigger_kind()
Maps to TriggerKind enum
as_job_trigger_kind()
Maps to JobTriggerKind enum
token_endpoint()
OAuth token endpoint (relative or absolute)
auth_endpoint()
OAuth authorization endpoint
oauth_scopes()
Space-separated OAuth scopes
resource_type()
Resource type for token storage (e.g., "gworkspace")
extra_auth_params()
Extra OAuth params (e.g., Google needs access_type=offline, prompt=consent)
integration_service()
Maps to the workspace integration service (usually *self)
TryFrom<String>
Parse from string
Display
Delegates to as_str()
Step-by-Step Implementation Guide
Step 1: Database Migration
Create a new migration file: backend/migrations/YYYYMMDDHHMMSS_newservice_trigger.up.sql
-- Add the service to the native_trigger_service enumALTER TYPE native_trigger_service ADDVALUE IF NOTEXISTS'newservice';
-- Add to TRIGGER_KIND enum (used for trigger tracking)ALTER TYPE TRIGGER_KIND ADDVALUE IF NOTEXISTS'newservice';
-- Add to job_trigger_kind enum (used for job tracking)ALTER TYPE job_trigger_kind ADDVALUE IF NOTEXISTS'newservice';
Also create the corresponding down migration.
Step 2: Update windmill-common Enums
backend/windmill-common/src/triggers.rs
Add variant to TriggerKind enum, and update to_key() and fmt() implementations.
backend/windmill-common/src/jobs.rs
Add variant to JobTriggerKind enum and update the Display implementation.
Step 3: Backend Service Module
Create a new directory: backend/windmill-native-triggers/src/newservice/
mod.rs - Type Definitions
use serde::{Deserialize, Serialize};
pubmod external;
// pub mod routes; // Only if you need additional service-specific routes/// OAuth data deserialized from the three-table pattern./// The actual structure is built by decrypt_oauth_data() from variable + account + workspace_integrations.#[derive(Debug, Clone, Deserialize, Serialize)]pubstructNewServiceOAuthData {
pub base_url: String, // from workspace_integrations.oauth_datapub access_token: String, // decrypted from variable tablepub refresh_token: Option<String>, // from account table// Note: client_id and client_secret are in OAuthConfig, not here// unless the service needs them at runtime for API calls
}
/// Configuration provided by user when creating/updating a trigger./// Stored as JSON in native_trigger.service_config.#[derive(Debug, Clone, Serialize, Deserialize)]#[serde(rename_all = "camelCase")]pubstructNewServiceConfig {
// Service-specific configuration fieldspub folder_path: String,
pub file_filter: Option<String>,
}
/// Data retrieved from the external service about a trigger./// Returned by the get() method and shown in the UI.#[derive(Debug, Clone, Serialize, Deserialize)]#[serde(rename_all = "camelCase")]pubstructNewServiceTriggerData {
pub folder_path: String,
pub file_filter: Option<String>,
// Fields that shouldn't affect service_config comparison should use #[serde(skip_serializing)]
}
/// Response from external service when creating a trigger/webhook.#[derive(Debug, Deserialize)]pubstructCreateTriggerResponse {
pub id: String,
}
/// Handler struct (stateless, used for routing)#[derive(Copy, Clone)]pubstructNewService;
external.rs - External Trait Implementation
use async_trait::async_trait;
use reqwest::Method;
use sqlx::PgConnection;
use std::collections::HashMap;
use windmill_common::{
error::{Error, Result},
BASE_URL, DB,
};
use crate::{
generate_webhook_service_url, External, NativeTrigger, NativeTriggerData, ServiceName,
sync::{SyncError, TriggerSyncInfo},
};
use super::{NewService, NewServiceConfig, NewServiceOAuthData, NewServiceTriggerData, CreateTriggerResponse};
#[async_trait]implExternalforNewService {
typeServiceConfig = NewServiceConfig;
typeTriggerData = NewServiceTriggerData;
typeOAuthData = NewServiceOAuthData;
typeCreateResponse = CreateTriggerResponse;
const SERVICE_NAME: ServiceName = ServiceName::NewService;
const DISPLAY_NAME: &'staticstr = "New Service";
const SUPPORT_WEBHOOK: bool = true;
const TOKEN_ENDPOINT: &'staticstr = "/oauth/token";
const REFRESH_ENDPOINT: &'staticstr = "/oauth/token";
const AUTH_ENDPOINT: &'staticstr = "/oauth/authorize";
asyncfncreate(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) ->Result<Self::CreateResponse> {
letbase_url = &*BASE_URL.read().await;
// external_id is None during create (we get it from the response)letwebhook_url = generate_webhook_service_url(
base_url, w_id, &data.script_path, data.is_flow,
None, Self::SERVICE_NAME, webhook_token,
);
leturl = format!("{}/api/webhooks/create", oauth_data.base_url);
letpayload = serde_json::json!({
"callback_url": webhook_url,
"folder_path": data.service_config.folder_path,
});
letresponse: CreateTriggerResponse = self
.http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload))
.await?;
Ok(response)
}
/// Update returns the resolved service_config as JSON./// For services using the update+get pattern, call self.get() and serialize.asyncfnupdate(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) ->Result<serde_json::Value> {
letbase_url = &*BASE_URL.read().await;
letwebhook_url = generate_webhook_service_url(
base_url, w_id, &data.script_path, data.is_flow,
Some(external_id), Self::SERVICE_NAME, webhook_token,
);
leturl = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
letpayload = serde_json::json!({
"callback_url": webhook_url,
"folder_path": data.service_config.folder_path,
});
let_: serde_json::Value = self
.http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload))
.await?;
// Fetch back the updated state to get the resolved configlettrigger_data = self.get(w_id, oauth_data, external_id, db, tx).await?;
serde_json::to_value(&trigger_data)
.map_err(|e| Error::InternalErr(format!("Failed to serialize trigger data: {}", e)))
}
asyncfnget(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) ->Result<Self::TriggerData> {
leturl = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await
}
asyncfndelete(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) ->Result<()> {
leturl = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
let_: serde_json::Value = self
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, None)
.await
.or_else(|e| match &e {
Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null),
_ => Err(e),
})?;
Ok(())
}
asyncfnexists(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) ->Result<bool> {
matchself.get(w_id, oauth_data, external_id, db, tx).await {
Ok(_) => Ok(true),
Err(Error::NotFound(_)) => Ok(false),
Err(e) => Err(e),
}
}
/// Background maintenance. Choose the right pattern for your service:/// - For services with queryable external state: use reconcile_with_external_state()/// - For channel-based services with expiration: implement renewal logicasyncfnmaintain_triggers(
&self,
db: &DB,
workspace_id: &str,
triggers: &[NativeTrigger],
oauth_data: &Self::OAuthData,
synced: &mutVec<TriggerSyncInfo>,
errors: &mutVec<SyncError>,
) {
// Option A: Reconcile with external state (Nextcloud pattern)// Fetch all triggers from external service and compare with DBletexternal_triggers = matchself.list_all(workspace_id, oauth_data, db).await {
Ok(triggers) => triggers,
Err(e) => {
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to list triggers: {}", e),
error_type: "api_error".to_string(),
});
return;
}
};
// Convert to (external_id, config_json) pairsletexternal_pairs: Vec<(String, serde_json::Value)> = external_triggers
.into_iter()
.map(|t| (t.id.clone(), serde_json::to_value(&t).unwrap_or_default()))
.collect();
crate::sync::reconcile_with_external_state(
db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors,
).await;
}
fnexternal_id_and_metadata_from_response(
&self,
resp: &Self::CreateResponse,
) -> (String, Option<serde_json::Value>) {
(resp.id.clone(), None)
}
// service_config_from_create_response: NOT overridden (returns None).// This means the handler uses the update+get pattern after create.// Override and return Some(...) to skip the update+get cycle (Google pattern).
}
implNewService {
/// Private helper to list all triggers from the external service.asyncfnlist_all(
&self,
w_id: &str,
oauth_data: &<Selfas External>::OAuthData,
db: &DB,
) ->Result<Vec<<Selfas External>::TriggerData>> {
// Implementation depends on the external service's API
todo!()
}
}
Step 4: Update lib.rs Registry
In backend/windmill-native-triggers/src/lib.rs:
// Service modules - add new services here:#[cfg(feature = "native_trigger")]pubmod newservice; // <-- Add this// ServiceName enum - add variant:pubenumServiceName {
Nextcloud,
Google,
NewService, // <-- Add this
}
// Then add match arms in ALL ServiceName methods:// as_str(), as_trigger_kind(), as_job_trigger_kind(), token_endpoint(),// auth_endpoint(), oauth_scopes(), resource_type(), extra_auth_params(),// integration_service(), TryFrom<String>, Display
Step 5: Update handler.rs Routes
In backend/windmill-native-triggers/src/handler.rs:
Check frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte to ensure it dynamically loads form components based on service name.
Step 11: Workspace Integration UI
Add your service to the supportedServices map in frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte:
constsupportedServices: Record<string, ServiceConfig> = {
// ... existing services ...newservice: {
name: 'newservice',
displayName: 'New Service',
description: 'Connect to New Service for triggers',
icon: NewServiceIcon,
docsUrl: 'https://www.windmill.dev/docs/integrations/newservice',
requiresBaseUrl: false, // false for cloud services, true for self-hostedsetupInstructions: [
'Step 1: Create an OAuth app on the service',
'Step 2: Configure the redirect URI shown below',
'Step 3: Enter the client credentials below'
]
}
}
In frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte:
Import the icon
Add to baseConfig with countKey (the dynamic availableNativeServices loop does NOT set countKey)
Add to the allTypes array
Step 14: Update TriggersWrapper.svelte
In frontend/src/lib/components/triggers/TriggersWrapper.svelte:
Add a {:else if selectedTrigger.type === 'yourservice'} case that renders <NativeTriggersPanel service="yourservice" ...> with the same props pattern as the existing native trigger cases (e.g., nextcloud).
Step 15: Update AddTriggersButton.svelte
In frontend/src/lib/components/triggers/AddTriggersButton.svelte:
Add yourserviceAvailable state variable
Add setYourserviceState() async function using isServiceAvailable('yourservice', $workspaceStore!)
Call it at module level
Add a dropdown entry to addTriggerItems with hidden: !yourserviceAvailable
In frontend/src/lib/components/triggers/TriggersEditor.svelte:
Add your service to the nativeTriggerServices map in deleteDeployedTrigger(). Native triggers use NativeTriggerService.deleteNativeTrigger({ workspace, serviceName, externalId }) instead of the standard path-based delete.
Step 17: Update getUsedTriggers for Sidebar Visibility
The sidebar (frontend/src/lib/components/sidebar/SidebarContent.svelte) shows native-trigger links only if $usedTriggerKinds includes the service — without this, your trigger page will never appear in the nav bar even when triggers exist.
Backend — add {service}_used: bool to the UsedTriggers struct and SELECT in backend/windmill-api-workspaces/src/workspaces.rs::get_used_triggers():
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = '{service}'::native_trigger_service) AS "{service}_used!"
OpenAPI — add {service}_used: boolean to the response schema for GET /w/{workspace}/workspaces/used_triggers (under both properties and required).
Layout — in frontend/src/routes/(root)/(logged)/+layout.svelte::loadUsedTriggerKinds(), destructure {service}_used and push '{service}' to usedKinds.
Step 18: Update OpenAPI Spec and Regenerate Types
Add to JobTriggerKind enum in backend/windmill-api/openapi.yaml, then:
cd frontend && npm run generate-backend-client
Special Patterns
Unified Service with trigger_type (Google Pattern)
When a single service handles multiple trigger types (e.g., Google Drive + Calendar share OAuth and API patterns), use a single ServiceName variant with a discriminator field:
pubenumGoogleTriggerType { Drive, Calendar }
pubstructGoogleServiceConfig {
pub trigger_type: GoogleTriggerType,
// Drive-specific fields (only used when trigger_type = Drive)pub resource_id: Option<String>,
pub resource_name: Option<String>,
// Calendar-specific fields (only used when trigger_type = Calendar)pub calendar_id: Option<String>,
pub calendar_name: Option<String>,
// Metadata set after creationpub google_resource_id: Option<String>,
pub expiration: Option<String>,
}
Branch in trait methods based on trigger_type. Frontend uses a ToggleButtonGroup to switch between types. This keeps the codebase simpler (one service, one OAuth flow, one set of routes).
See backend/windmill-native-triggers/src/google/ for the reference implementation.
Skipping update+get After Create (Google Pattern)
Override service_config_from_create_response() to return Some(config) when the external_id is known before the create call:
When workspace_integrations.oauth_data.instance_shared == true, decrypt_oauth_data() reads client_id and client_secret from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces.
The frontend handles this via the generate_instance_connect_url endpoint in workspace_integrations.rs.