本文へ移動
cccskills
無料GitHub で公開日本語紹介

native-trigger

Windmillに外部サービスのWebhookやプッシュ通知をつなぐネイティブトリガーを追加・変更するため、バックエンドと画面の実装手順を案内します。

原文原文の説明を見る

Guidance for adding native trigger services to Windmill. Use when implementing or modifying native trigger integrations across the backend and frontend.

windmill-labs/windmillスター 18,276(GitHub 18,276 + cccskills 0)
連携ツールGoogle Drive

実行スコア 未検証

インストール方法を見る

こんなときに便利

  • 外部サービスのトリガーを追加したいとき
  • 既存の連携機能を変更したいとき
  • OAuthやWebhookの実装箇所を整理したいとき

日本語での紹介

できること

Windmillに外部サービスのネイティブトリガーを追加・変更する際の実装箇所と手順を案内します。トリガーは、NextcloudやGoogle DriveなどのサービスからWebhookやプッシュ通知を受け、Windmillのスクリプトやフローを起動する仕組みです。データベースの型とマイグレーション、Rustバックエンドのサービス実装、OAuth接続、定期同期、Svelteの設定フォームや一覧表示まで、関連する変更点を順に確認できます。既存のGoogleやNextcloudの実装パターンも参照し、作成後に外部IDを反映する方式など、サービスに応じた構成を選ぶための情報があります。

こんなときに便利

Windmillに新しい外部サービス連携を追加するときや、既存トリガーのバックエンドと画面をまとめて変更するときに役立ちます。OAuth接続や外部サービス側のトリガー状態を、どのように扱うか整理したい開発者にも向いています。

使い方の例

  • 「この外部サービス用のネイティブトリガーを追加する手順を整理して」
  • 「既存のトリガー連携を変更する際に更新が必要な箇所を確認して」

注意点

WindmillのバックエンドRustコードとフロントエンドSvelteコードを扱う実装ガイドです。資料は途中までのため、後半の内容は含まれていません。

この紹介文は、公開されている SKILL.md をもとに AI(OpenAI Codex)が作成しました。正確な仕様は下の原文を確認してください。

含まれるファイル(1)

  • SKILL.md32.6 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

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:

  1. Database Layer - PostgreSQL tables and enum types
  2. Backend Rust Implementation - Core trait, handlers, and service modules in the windmill-native-triggers crate
  3. Frontend Svelte Components - Configuration forms and UI components

Key Files

ComponentPath
Core module with External traitbackend/windmill-native-triggers/src/lib.rs
Generic CRUD handlersbackend/windmill-native-triggers/src/handler.rs
Background sync logicbackend/windmill-native-triggers/src/sync.rs
OAuth/workspace integrationbackend/windmill-native-triggers/src/workspace_integrations.rs
Re-export shim (windmill-api)backend/windmill-api/src/native_triggers/mod.rs
TriggerKind enumbackend/windmill-common/src/triggers.rs
JobTriggerKind enumbackend/windmill-common/src/jobs.rs
Frontend service registryfrontend/src/lib/components/triggers/native/utils.ts
Frontend trigger utilitiesfrontend/src/lib/components/triggers/utils.ts
Trigger badges (icons + counts)frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte
Workspace integrations UIfrontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte
OAuth config form componentfrontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte
OpenAPI specbackend/windmill-api/openapi.yaml
Reference: Nextcloud modulebackend/windmill-native-triggers/src/nextcloud/
Reference: Google modulebackend/windmill-native-triggers/src/google/

Crate Structure

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:

// backend/windmill-api/src/native_triggers/mod.rs
pub use windmill_native_triggers::*;

All new service modules go in backend/windmill-native-triggers/src/.


Core Concepts

The External Trait

Every native trigger service implements the External trait defined in lib.rs:

#[async_trait]
pub trait External: Send + Sync + 'static {
    // Associated types:
    type ServiceConfig: Debug + DeserializeOwned + Serialize + Send + Sync;
    type TriggerData: Debug + Serialize + Send + Sync;
    type OAuthData: DeserializeOwned + Serialize + Clone + Send + Sync;
    type CreateResponse: DeserializeOwned + Send + Sync;

    // Constants:
    const SUPPORT_WEBHOOK: bool;
    const SERVICE_NAME: ServiceName;
    const DISPLAY_NAME: &'static str;
    const TOKEN_ENDPOINT: &'static str;
    const REFRESH_ENDPOINT: &'static str;
    const AUTH_ENDPOINT: &'static str;

    // Required methods:
    async fn create(&self, w_id, oauth_data, webhook_token, data, db, tx) -> Result<Self::CreateResponse>;
    async fn update(&self, w_id, oauth_data, external_id, webhook_token, data, db, tx) -> Result<serde_json::Value>;
    async fn get(&self, w_id, oauth_data, external_id, db, tx) -> Result<Self::TriggerData>;
    async fn delete(&self, w_id, oauth_data, external_id, db, tx) -> Result<()>;
    async fn exists(&self, w_id, oauth_data, external_id, db, tx) -> Result<bool>;
    async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors);
    fn external_id_and_metadata_from_response(&self, resp) -> (String, Option<serde_json::Value>);

    // Methods with defaults:
    async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned>;
    fn service_config_from_create_response(&self, data, resp) -> Option<serde_json::Value>;
    fn additional_routes(&self) -> axum::Router;
    async fn http_client_request<T, B>(&self, url, method, workspace_id, connection_path, db, headers, body) -> Result<T>;
}

Key design points:

  • 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):

  1. create() registers on external service
  2. external_id_and_metadata_from_response() extracts the ID
  3. service_config_from_create_response() builds the config directly from input data + response metadata
  4. 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):

  1. create() registers on external service (webhook URL has no external_id yet)
  2. external_id_and_metadata_from_response() extracts the ID
  3. update() is called to fix the webhook URL with the now-known external_id
  4. update() returns the resolved service_config
  5. 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 App and Connections

The OAuth app (client) is configured per workspace and service; connections (connected accounts) are many per service. Each trigger acts through one connection, named by native_trigger.connection_path:

TableWhat's Stored
workspace_integrationsoauth_data JSON with base_url, client_id, client_secret, instance_shared flag. One row per (workspace, service); it must exist for every connection, since token refresh reads the client from it
variableEncrypted access_token at the connection path, linked to account via its account column
accountrefresh_token, with client = service name and is_workspace_integration = true
resourceThe connection's resource at the same path, { "token": "$var:<path>" }

A user may use a connection when they can read its variable (checked with a user transaction, so the variable's row-level security decides): resolve_usable_connection / list_usable_connections in lib.rs. The exception is the workspace connection: an admin may share one connection per service (workspace_integrations.resource_path, set by POST .../workspace_connection), and every member may then use it without reading its token. Renames carry the marker along; disconnecting clears it. Background work (sync, renewal, rename re-registration, webhook handling) uses the stored connection_path directly.

decrypt_oauth_data(db, w_id, service, connection_path) assembles these into one struct, and every service's OAuthData carries connection_path so http_client_request can refresh and write back the right connection:

pub struct OAuthConfig {
    pub base_url: String,
    pub access_token: String,      // decrypted from the connection's variable
    pub refresh_token: Option<String>, // from its account
    pub client_id: String,         // from oauth_data or instance settings
    pub client_secret: String,     // from oauth_data or instance settings
    pub connection_path: String,
}

A refreshed token is written back by connection path and account id, never by service: providers like Nextcloud rotate refresh tokens, so writing one onto another account breaks its next refresh.

Instance-level sharing: when the workspace has no OAuth app of its own and the instance admin shares one (share_with_workspaces in the oauths setting; not available for Nextcloud), the connect flow uses it and records {"instance_shared": true} in workspace_integrations.

URL Resolution

The resolve_endpoint() helper handles both absolute and relative OAuth URLs:

pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String {
    if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
        endpoint.to_string()  // Google: absolute URLs
    } else {
        format!("{}{}", base_url, endpoint)  // Nextcloud: relative paths
    }
}

ServiceName Methods

ServiceName is the central registry enum. Each variant must implement these match arms:

MethodPurpose
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
DisplayDelegates 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 enum
ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'newservice';

-- Add to TRIGGER_KIND enum (used for trigger tracking)
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'newservice';

-- Add to job_trigger_kind enum (used for job tracking)
ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS '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};

pub mod 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)]
pub struct NewServiceOAuthData {
    pub base_url: String,              // from workspace_integrations.oauth_data
    pub access_token: String,          // decrypted from variable table
    pub 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")]
pub struct NewServiceConfig {
    // Service-specific configuration fields
    pub 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")]
pub struct NewServiceTriggerData {
    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)]
pub struct CreateTriggerResponse {
    pub id: String,
}

/// Handler struct (stateless, used for routing)
#[derive(Copy, Clone)]
pub struct NewService;

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]
impl External for NewService {
    type ServiceConfig = NewServiceConfig;
    type TriggerData = NewServiceTriggerData;
    type OAuthData = NewServiceOAuthData;
    type CreateResponse = CreateTriggerResponse;

    const SERVICE_NAME: ServiceName = ServiceName::NewService;
    const DISPLAY_NAME: &'static str = "New Service";
    const SUPPORT_WEBHOOK: bool = true;
    const TOKEN_ENDPOINT: &'static str = "/oauth/token";
    const REFRESH_ENDPOINT: &'static str = "/oauth/token";
    const AUTH_ENDPOINT: &'static str = "/oauth/authorize";

    async fn create(
        &self,
        w_id: &str,
        oauth_data: &Self::OAuthData,
        webhook_token: &str,
        data: &NativeTriggerData<Self::ServiceConfig>,
        db: &DB,
        tx: &mut PgConnection,
    ) -> Result<Self::CreateResponse> {
        let base_url = &*BASE_URL.read().await;

        // external_id is None during create (we get it from the response)
        let webhook_url = generate_webhook_service_url(
            base_url, w_id, &data.script_path, data.is_flow,
            None, Self::SERVICE_NAME, webhook_token,
        );

        let url = format!("{}/api/webhooks/create", oauth_data.base_url);
        let payload = serde_json::json!({
            "callback_url": webhook_url,
            "folder_path": data.service_config.folder_path,
        });

        let response: CreateTriggerResponse = self
            .http_client_request(&url, Method::POST, w_id, &oauth_data.connection_path, 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.
    async fn update(
        &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> {
        let base_url = &*BASE_URL.read().await;

        let webhook_url = generate_webhook_service_url(
            base_url, w_id, &data.script_path, data.is_flow,
            Some(external_id), Self::SERVICE_NAME, webhook_token,
        );

        let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
        let payload = 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, &oauth_data.connection_path, db, None, Some(&payload))
            .await?;

        // Fetch back the updated state to get the resolved config
        let trigger_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)))
    }

    async fn get(
        &self,
        w_id: &str,
        oauth_data: &Self::OAuthData,
        external_id: &str,
        db: &DB,
        tx: &mut PgConnection,
    ) -> Result<Self::TriggerData> {
        let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
        self.http_client_request::<_, ()>(&url, Method::GET, w_id, &oauth_data.connection_path, db, None, None).await
    }

    async fn delete(
        &self,
        w_id: &str,
        oauth_data: &Self::OAuthData,
        external_id: &str,
        db: &DB,
        tx: &mut PgConnection,
    ) -> Result<()> {
        let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
        let _: serde_json::Value = self
            .http_client_request::<_, ()>(&url, Method::DELETE, w_id, &oauth_data.connection_path, db, None, None)
            .await
            .or_else(|e| match &e {
                Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null),
                _ => Err(e),
            })?;
        Ok(())
    }

    async fn exists(
        &self,
        w_id: &str,
        oauth_data: &Self::OAuthData,
        external_id: &str,
        db: &DB,
        tx: &mut PgConnection,
    ) -> Result<bool> {
        match self.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 logic
    async fn maintain_triggers(
        &self,
        db: &DB,
        workspace_id: &str,
        triggers: &[NativeTrigger],
        oauth_data: &Self::OAuthData,
        synced: &mut Vec<TriggerSyncInfo>,
        errors: &mut Vec<SyncError>,
    ) {
        // Option A: Reconcile with external state (Nextcloud pattern)
        // Fetch all triggers from external service and compare with DB
        let external_triggers = match self.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) pairs
        let external_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;
    }

    fn external_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).
}

impl NewService {
    /// Private helper to list all triggers from the external service.
    async fn list_all(
        &self,
        w_id: &str,
        oauth_data: &<Self as External>::OAuthData,
        db: &DB,
    ) -> Result<Vec<<Self as 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")]
pub mod newservice;  // <-- Add this

// ServiceName enum - add variant:
pub enum ServiceName {
    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:

pub fn generate_native_trigger_routers() -> Router {
    // ...
    #[cfg(feature = "native_trigger")]
    {
        use crate::newservice::NewService;
        return router
            .nest("/nextcloud", service_routes(NextCloud))
            .nest("/google", service_routes(Google))
            .nest("/newservice", service_routes(NewService));  // <-- Add this
    }
    // ...
}

Step 6: Update sync.rs

In backend/windmill-native-triggers/src/sync.rs:

pub async fn sync_all_triggers(db: &DB) -> Result<BackgroundSyncResult> {
    // ...
    #[cfg(feature = "native_trigger")]
    {
        use crate::newservice::NewService;

        // ... existing service syncs ...

        // New service sync
        let (service_name, result) = sync_service_triggers(db, NewService).await;
        total_synced += result.synced_triggers.len();
        total_errors += result.errors.len();
        service_results.insert(service_name, result);
    }
    // ...
}

Step 7: Frontend Service Registry

In frontend/src/lib/components/triggers/native/utils.ts:

Add to NATIVE_TRIGGER_SERVICES, getTriggerIconName(), and getServiceIcon().

Step 8: Frontend Trigger Form Component

Create: frontend/src/lib/components/triggers/native/services/newservice/NewServiceTriggerForm.svelte

Step 9: Frontend Icon Component

Create: frontend/src/lib/components/icons/NewServiceIcon.svelte

Step 10: Update NativeTriggerEditor

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:

const supportedServices: 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-hosted
        setupInstructions: [
            'Step 1: Create an OAuth app on the service',
            'Step 2: Configure the redirect URI shown below',
            'Step 3: Enter the client credentials below'
        ]
    }
}

Step 12: Update frontend/src/lib/components/triggers/utils.ts

Update ALL of these maps/functions:

  1. triggerIconMap - import and add icon
  2. triggerDisplayNamesMap - add display name
  3. triggerTypeOrder in sortTriggers() - add type
  4. getLightConfig() - add case for your service
  5. getTriggerLabel() - add case for your service
  6. jobTriggerKinds - add to array
  7. countPropertyMap - add count property
  8. triggerSaveFunctions - add save function

Step 13: Update TriggersBadge Component

In frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte:

  1. Import the icon
  2. Add to baseConfig with countKey (the dynamic availableNativeServices loop does NOT set countKey)
  3. 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:

  1. Add yourserviceAvailable state variable
  2. Add setYourserviceState() async function using isServiceAvailable('yourservice', $workspaceStore!)
  3. Call it at module level
  4. Add a dropdown entry to addTriggerItems with hidden: !yourserviceAvailable

Step 16: Update TriggersEditor.svelte Delete Handling

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.

  1. 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!"
    
  2. OpenAPI — add {service}_used: boolean to the response schema for GET /w/{workspace}/workspaces/used_triggers (under both properties and required).
  3. 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:

pub enum GoogleTriggerType { Drive, Calendar }

pub struct GoogleServiceConfig {
    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 creation
    pub 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:

fn service_config_from_create_response(
    &self,
    data: &NativeTriggerData<Self::ServiceConfig>,
    resp: &Self::CreateResponse,
) -> Option<serde_json::Value> {
    // Clone input config, add metadata from response
    let mut config = data.service_config.clone();
    config.google_resource_id = Some(resp.resource_id.clone());
    config.expiration = Some(resp.expiration.clone());
    Some(serde_json::to_value(&config).unwrap())
}

Services with Absolute OAuth Endpoints (Google)

Unlike self-hosted services where OAuth endpoints are relative paths appended to base_url, services like Google have absolute URLs:

// Nextcloud: relative paths
ServiceName::Nextcloud => "/apps/oauth2/api/v1/token",
// Google: absolute URLs
ServiceName::Google => "https://oauth2.googleapis.com/token",

The resolve_endpoint() function handles both. For services with absolute endpoints:

  • base_url can be empty
  • requiresBaseUrl: false in the frontend workspace integration config
  • Add extra_auth_params() if needed (Google requires access_type=offline and prompt=consent)

Channel-Based Push Notifications with Renewal (Google Pattern)

For services using expiring watch channels instead of persistent webhooks:

  1. Store expiration in service_config (as part of ServiceConfig)
  2. In maintain_triggers(), implement renewal logic instead of using reconcile_with_external_state():
    async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors) {
        for trigger in triggers {
            if should_renew_channel(trigger) {
                self.renew_channel(db, trigger, oauth_data).await;
            }
        }
    }
    
  3. Renewal: best-effort stop old channel, create new one with same external_id, update service_config with new expiration
  4. Google example: Drive channels expire in 24h (renew when <1h left), Calendar channels expire in 7 days (renew when <1 day left)

reconcile_with_external_state (Nextcloud Pattern)

The reusable function in sync.rs compares external triggers with DB state:

  • Triggers missing externally: sets error "Trigger no longer exists on external service"
  • Triggers present externally: clears errors, updates service_config if it differs

Usage in maintain_triggers():

let external_pairs: Vec<(String, serde_json::Value)> = /* fetch from external */;
crate::sync::reconcile_with_external_state(
    db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors,
).await;

Webhook Payload Processing

Override prepare_webhook() to parse service-specific payloads into script/flow args:

async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned> {
    let mut args = HashMap::new();
    args.insert("event_type".to_string(), Box::new(headers.get("x-event-type").cloned()) as _);
    args.insert("payload".to_string(), Box::new(serde_json::from_str::<serde_json::Value>(&body)?) as _);
    Ok(PushArgsOwned { extra: None, args })
}

Then register in prepare_native_trigger_args() in lib.rs:

pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) -> Result<Option<PushArgsOwned>> {
    match service_name {
        ServiceName::Google => { /* ... */ Ok(Some(args)) }
        ServiceName::NewService => { /* ... */ Ok(Some(args)) }
        ServiceName::Nextcloud => Ok(None), // Uses default body parsing
    }
}

Instance-Level OAuth Credentials

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.

generate_connect_url picks the client (resolve_oauth_client): the workspace's own app when configured, the instance's otherwise. A GitHub OAuth app only redirects below its single registered callback, so connections made with the instance GitHub app land on /oauth/callback/github/native_trigger instead of the workspace settings page.


Testing Checklist

  • Database migration runs successfully
  • cargo check -p windmill-native-triggers --features native_trigger passes
  • npx svelte-check --threshold error passes (in frontend/)
  • Service appears in workspace integrations list
  • OAuth flow completes successfully
  • Can create a new trigger
  • Can view trigger details
  • Can update trigger configuration
  • Can delete trigger
  • Webhook receives and processes payloads
  • Background sync works correctly (reconciliation or channel renewal)
  • Error handling works (expired tokens, service unavailable)

Reference Implementations

Nextcloud (Self-Hosted, Update+Get Pattern)

FilePurpose
nextcloud/mod.rsTypes: NextCloudOAuthData, NextcloudServiceConfig, NextCloudTriggerData
nextcloud/external.rsExternal trait: uses update+get pattern, reconcile_with_external_state for sync
nextcloud/routes.rsAdditional route: GET /events

Key patterns: relative OAuth endpoints, base_url required, list_all + reconcile for sync, update returns JSON from get().

Google (Cloud, Unified Service, Short Create)

FilePurpose
google/mod.rsTypes: GoogleServiceConfig with trigger_type discriminator, GoogleTriggerType enum
google/external.rsExternal trait: overrides service_config_from_create_response, channel renewal for sync
google/routes.rsAdditional routes: GET /calendars, GET /drive/files, GET /drive/shared_drives

Key patterns: absolute OAuth endpoints, empty base_url, trigger_type for Drive/Calendar, expiring watch channels with renewal, service_config_from_create_response skips update+get, get() reconstructs data from stored service_config (no external "get channel" API).

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

コメント

ログインなしで書き込めます。使ってみた感想、質問、動かなかった環境などを気軽にどうぞ。

まだコメントはありません。最初のコメントを書いてみませんか。

送信するとすぐに公開されます。メールアドレスや電話番号などの個人情報は書かないでください。URL は 2 つまでです。

0 / 1000 文字

このフォームは reCAPTCHA で保護されており、Google のプライバシーポリシーと利用規約が適用されます。

同じリポジトリのスキル

adding-a-trigger

無料概要あり

Windmillに新しいTriggerCrud型のトリガーを追加する際、バックエンドからCLI、画面、同期やキャプチャ機能まで必要な変更箇所を確認できる開発チェックリストです。

  • 新しい外部サービスのトリガーを追加したいとき
  • 実装箇所の抜け漏れを点検したいとき
  • トリガー追加のPR前に検証したいとき
windmill-labs/windmill1.8万2026年10月26日 更新

ai-chat

無料概要あり

WindmillのAIチャットを変更するときに、ツールやプロンプト、結果の返し方、コンテキスト量を整理し、評価で変更前後の挙動を比べるための指針です。

  • チャットツールを変更したいとき
  • プロンプトの配置を見直したいとき
  • コンテキスト消費を抑えたいとき
windmill-labs/windmill1.8万2026年10月26日 更新

ai-evals

無料概要あり

WindmillのAI生成モード向けに、実際の利用要求に沿ったベンチマークケースを作成・実行します。生成結果を決定的な検証とLLM評価で確認し、AIチャットやcopilotの変更前後を比較できます。

  • AI生成モードの評価ケースを追加したいとき
  • AIチャットの変更前後を比較したいとき
  • copilotの生成品質を確認したいとき
windmill-labs/windmill1.8万2026年10月26日 更新

cli-commands

無料概要あり

Windmill CLIのコマンドを使い、スクリプトやフローなどの管理、ジョブの実行履歴確認、失敗原因の調査を進めるためのリファレンスです。

  • 失敗したジョブの原因を調べたいとき
  • 実行履歴やジョブログを確認したいとき
  • CLIでスクリプトやフローを管理したいとき
windmill-labs/windmill1.8万2026年10月26日 更新

codebase-design

無料概要あり

モジュールのインターフェースを小さく保ちながら、内部に複雑な処理をまとめる設計を支援します。テストしやすさや変更のしやすさを考え、処理の境目を整理できます。

  • モジュールのインターフェースを設計したいとき
  • 既存モジュールの複雑さを整理したいとき
  • 処理を差し替える境目を決めたいとき
windmill-labs/windmill1.8万2026年10月26日 更新

commit

無料概要あり

変更内容を確認し、対象ファイルだけを選んで、形式や長さの規則に沿ったConventional Commits形式のGitコミットを作成するスキルです。

  • 機能追加をコミットしたいとき
  • バグ修正を記録したいとき
  • 変更内容に合うコミット文を作りたいとき
windmill-labs/windmill1.8万2026年10月26日 更新

windmill-labs のスキルをすべて見る

このスキルの問題を報告する