Windmillに新しいTriggerCrud型のトリガーを追加する際、バックエンドからCLI、画面、同期やキャプチャ機能まで必要な変更箇所を確認できる開発チェックリストです。
- 新しい外部サービスのトリガーを追加したいとき
- 実装箇所の抜け漏れを点検したいとき
- トリガー追加のPR前に検証したいとき
Windmill上で動くPythonスクリプトの構成や引数、リソース型、クライアントの使い方を整え、プレビューやメタデータ更新を案内するスキルです。
MUST use when writing Python scripts.
実行スコア 未検証
インストール方法を見るWindmill向けPythonスクリプトの構成を整えます。スクリプトには main 関数を定義し、呼び出し自体は書きません。外部ライブラリは自動でインストールされる前提で、インストール手順は加えません。認証情報や接続設定は環境変数に直書きせず、リソースとして引数から受け取ります。必要なリソース型は TypedDict で定義し、型名は小文字にします。
Windmillとの連携には wmill クライアントを使い、スクリプトやフローの実行、ジョブ状態の取得、変数やリソースの操作などを行う方法が示されています。S3互換ストレージの読み書きや、イベントをフローの引数へ変換するプリプロセッサの構成も扱います。ローカル編集の確認には wmill script preview を使い、必要に応じてメタデータの更新を案内します。
Windmill上でPythonの自動処理を作る人や、認証情報を安全に引数として渡したい人に向いています。Windmillのジョブやリソース、S3ストレージをPythonから扱う処理を組み立てる際にも役立ちます。
wmill CLIとWindmill環境を前提とします。資料は途中までのため、後半に記載された可能性のある仕様は紹介に含めていません。
この紹介文は、公開されている SKILL.md をもとに AI(OpenAI Codex)が作成しました。正確な仕様は下の原文を確認してください。
インストールする前に、エージェントに与えられる指示の中身を確認できます。
Place scripts in a folder.
After writing, tell the user which command fits what they want to do:
wmill script preview <script_path> — default when iterating on a local script. Runs the local file without deploying.wmill script run <path> — runs the script already deployed in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.wmill generate-metadata — regenerate the local .script.yaml (input schema) and .lock (resolved dependencies) for scripts you changed, and refresh their content hashes in wmill-lock.yaml. Local files only — not a deploy. See "Keep metadata in sync" below.git push or wmill sync push depending on how the repo is wired (see the Deploying section in AGENTS.wmill.md). Only suggest/run a deploy when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".If the user says "run the script", "try it", "test it", "does it work" while there are local edits to the script file, use script preview. Do NOT push the script to then script run it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes.
Only use script run when:
Only use sync push when:
wmill-lock.yaml tracks a content hash for each item. Editing a script's content — most importantly adding or removing an import or changing main's arguments — invalidates that hash and leaves the .lock, the .script.yaml input schema, and the hash row out of date. Run wmill generate-metadata (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by .script.yaml), and wmill-lock.yaml all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
This only writes local files (it is not a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's AGENTS.md opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated .lock / .script.lock files and tell the user which dependency versions changed (e.g. requests 2.31.0 → 2.32.0), so they can catch an unwanted bump before deploying — even under Metadata: auto, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
With no path argument, generate-metadata regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run wmill generate-metadata --dry-run — it lists each stale item with a reason (content changed or depends on <path>) without changing anything — then narrow with a path argument (wmill generate-metadata f/foo) or --strict-folder-boundaries.
If the on-disk .lock and .script.yaml are already correct and only wmill-lock.yaml needs its hashes refreshed (hash drift, or bootstrapping missing entries), use wmill generate-metadata rehash — it re-records hashes from disk with no backend round-trip and no dependency changes.
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run wmill script preview with sample args?"). Do not present a multi-option menu.
If the user already asked to test/run/try the script in their original request, skip the offer and just execute wmill script preview <path> -d '<args>' directly — pick plausible args from the script's declared parameters. The shape varies by language: main(...) for code languages, the SQL dialect's own placeholder syntax ($1 for PostgreSQL, ? for MySQL/Snowflake, @P1 for MSSQL, @name for BigQuery, etc.), positional $1, $2, … for Bash, param(...) for PowerShell.
wmill script preview does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). wmill generate-metadata does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's AGENTS.md opts in), per "Keep metadata in sync" above. Deploying to the workspace (git push or wmill sync push depending on how the repo is wired — see the Deploying section) is the only step that mutates remote state — do it only when the user explicitly asks to deploy/publish/push.
For a visual open-the-script-in-the-dev-page preview (rather than script preview's run-and-print-result), use the preview skill.
Use wmill resource-type list --schema to discover available resource types.
c_<name>, which keeps it apart from a hub type synced later: the parameter takes RT.C<Name> in TypeScript, a c_<name> TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating ittype Stripe = { token: string }), which is still the stripe resource type; in code you write, name it the way the language section shows (RT.Stripe in TypeScript) rather than copying the inline declarationwmill resource-type list: it shows whether the workspace has the type, and warns when the instance has not synced the hub's typesmain (Main in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no main: their language section shows how they take argumentswmill), use it to interact with the platformprompt_for_ai string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schemaresults.step_idPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.
A preprocessor is written in TypeScript or Python: its function is named preprocessor instead of main, and it receives a single parameter called event (the language section gives its type).
The returned object determines the parameter values passed to the flow.
e.g., { b: 1, a: 2 } calls the flow with a = 2 and b = 1, assuming the flow has two inputs called a and b.
The script must contain at least one function called main:
def main(param1: str, param2: int):
# Your code here
return {"result": param1, "count": param2}
Do not call the main function. Libraries are installed automatically.
On Windmill, credentials and configuration are stored in resources and passed as parameters to main.
You need to redefine the type of the resources that are needed before the main function as TypedDict:
from typing import TypedDict
class postgresql(TypedDict):
host: str
port: int
user: str
password: str
dbname: str
def main(db: postgresql):
# db contains the database connection details
pass
Important rules:
Libraries are installed automatically. Do not show installation instructions.
import requests
import pandas as pd
from datetime import datetime
If an import name conflicts with a resource type:
# Wrong - don't rename the type
import stripe as stripe_lib
class stripe_type(TypedDict): ...
# Correct - rename the import
import stripe as stripe_sdk
class stripe(TypedDict):
api_key: str
Import the windmill client for platform interactions:
import wmill
See the SDK documentation for available methods.
For preprocessor scripts, the function should be named preprocessor and receives an event parameter:
from typing import TypedDict, Literal, Any
class Event(TypedDict):
kind: Literal["webhook", "http", "websocket", "kafka", "email", "nats", "postgres", "sqs", "mqtt", "amqp", "gcp", "azure"]
body: Any
headers: dict[str, str]
query: dict[str, str]
def preprocessor(event: Event):
# Transform the event into flow input parameters
return {
"param1": event["body"]["field1"],
"param2": event["query"]["id"]
}
Windmill provides built-in support for S3-compatible storage operations.
To accept a file from S3 as input to a script, type the parameter with S3Object (imported from wmill):
import wmill
from wmill import S3Object
def main(file: S3Object):
content = wmill.load_s3_file(file)
# ...
import wmill
# Load file content from S3
content: bytes = wmill.load_s3_file(s3object)
# Load file as stream reader
reader: BufferedReader = wmill.load_s3_file_reader(s3object)
# Write file to S3
result: S3Object = wmill.write_s3_file(
s3object, # Target path (or None to auto-generate)
file_content, # bytes or BufferedReader
s3_resource_path, # Optional: specific S3 resource
content_type, # Optional: MIME type
content_disposition # Optional: Content-Disposition header
)
Import: import wmill
The client configures itself from the job's environment — base URL, token and credentials mode are all set before your code runs, so there is nothing to initialize and no reason to read WM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only reintroduces details the client already handles. Call the SDK for anything Windmill, and use raw HTTP for third-party APIs.
The functions below are the surface to prefer. For an endpoint none of them covers, wmill.Windmill().get(endpoint) and .post(endpoint) issue an authenticated request against this instance. What does not exist is a function name you guessed at: if it is not listed below, do not call it.
To know who is running the script, read the contextual variables rather than calling the API:
os.environ.get("WM_END_USER_EMAIL") or os.environ.get("WM_EMAIL"). WM_END_USER_EMAIL is the app
viewer when the run was triggered from an app and empty otherwise (both variables are always
defined), WM_EMAIL is the user the job is permissioned as. WM_USERNAME is the matching username.
def worker_has_internal_server() -> bool
def get_mocked_api() -> Optional[dict]
def get_client() -> httpx.Client
def get(endpoint, raise_for_status = True, **kwargs) -> httpx.Response
def post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response
def create_token(duration = dt.timedelta(days=1)) -> str
retry re-runs the job when it fails, with the policy a schedule takes,{"constant": {"attempts": 3, "seconds": 10}}. The returned id isdef run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str
run_script_by_path_async for retry.def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str
def run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str
def run_script_by_path(path: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False, tag: str = None) -> Any
def run_script_by_hash(hash_: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False, tag: str = None) -> Any
def run_inline_script_preview(content: str, language: str, args: dict = None) -> Any
def wait_job(job_id, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False)
def cancel_job(job_id: str, reason: str = None) -> str
def cancel_running() -> dict
def get_job(job_id: str) -> dict
def get_root_job_id(job_id: str | None = None) -> dict
def get_id_token(audience: str, expires_in: int | None = None) -> str
def get_job_status(job_id: str) -> JobStatus
def get_result(job_id: str, assert_result_is_not_none: bool = True) -> Any
def get_variable(path: str) -> str
def set_variable(path: str, value: str, is_secret: bool = False) -> None
def get_resource(path: str, none_if_undefined: bool = False, interpolated: bool = True) -> dict | None
def set_resource(value: Any, path: str, resource_type: str)
def list_resources(resource_type: str = None, page: int = None, per_page: int = None) -> list[dict]
def set_state(value: Any, path: str | None = None) -> None
def get_state(path: str | None = None) -> Any
def set_progress(value: int, job_id: Optional[str] = None)
def get_progress(job_id: Optional[str] = None) -> Any
def worker_is_draining(job_id: Optional[str] = None) -> bool
def set_flow_user_state(key: str, value: Any) -> None
def get_flow_user_state(key: str) -> Any
def version()
s3://<bucket>/<key> with thes3_bucket. Pass the resource path to connect withdef get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None
s3://<bucket>/<key> with thes3_bucket. Pass the resource path to connect withdef get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings
Bucket. The proxydef get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings
def load_s3_file(s3object: S3Object | str, s3_resource_path: str | None) -> bytes
def load_s3_file_reader(s3object: S3Object | str, s3_resource_path: str | None) -> BufferedReader
def write_s3_file(s3object: S3Object | str | None, file_content: BufferedReader | bytes, s3_resource_path: str | None, content_type: str | None = None, content_disposition: str | None = None) -> S3Object
def delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = None) -> None
def sign_s3_objects(s3_objects: list[S3Object | str], expiry_secs: int | None = None) -> list[S3Object]
def sign_s3_object(s3_object: S3Object | str, expiry_secs: int | None = None) -> S3Object
def get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None, expiry_secs: int | None = None) -> list[str]
def get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None, expiry_secs: int | None = None) -> str
def whoami() -> dict
def user() -> dict
def state_path() -> str
def state() -> Any
def set_shared_state_pickle(value: Any, path: str = 'state.pickle') -> None
def get_shared_state_pickle(path: str = 'state.pickle') -> Any
def set_shared_state(value: Any, path: str = 'state.json') -> None
def get_shared_state(path: str = 'state.json') -> None
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
wait_for_approval step of this workflow.wait_for_approval(key=...)def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict
WM_FLOW_JOB_ID, WM_FLOW_STEP_ID) to ensure it is run in the appropriate context.def request_interactive_slack_approval(slack_resource_path: str, channel_id: str, message: str = None, approver: str = None, default_args_json: dict = None, dynamic_enums_json: dict = None) -> None
def send_teams_message(conversation_id: str, text: str, success: bool = True, card_block: dict = None)
def datatable(name: str = 'main', *, role: Optional[str] = None)
def ducklake(name: str = 'main')
def init_global_client(f)
def deprecate(in_favor_of: str)
def get_workspace() -> str
def get_version() -> str
def run_script_async(hash_or_path: str, args: Dict[str, Any] = None, scheduled_in_secs: int = None, tag: str = None) -> str
def run_script_sync(hash: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None, tag: str = None) -> Any
def run_script_by_path_sync(path: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None, tag: str = None) -> Any
def duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings
def polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings
def boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings
def get_state_path() -> str
def parse_resource_syntax(s: str) -> Optional[str]
s3://<storage>/<key> URI string (s3:///<key>def parse_s3_object(s3_object: S3Object | str) -> S3Object
def parse_variable_syntax(s: str) -> Optional[str]
def append_to_result_stream(text: str) -> None
def stream_result(stream) -> None
def query(sql: str, *args) -> SqlQuery
select_sql into ducklaketable for one partition (or the whole table when partition is// materialize engine: withunique_key it upserts within the slice (delete-by-key + insert);select_sql is trusted (your own query).def upsert_partition(table: str, select_sql: str, partition: str = None, unique_key: str = None, partition_col: str = '_wm_partition', schema: str = None)
partition, or the whole table whenpartition is None. NOTE: unlike upsert_partition, re-running the samedef append_partition(table: str, select_sql: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)
def read(table: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)
def fetch(result_collection: str | None = None)
def fetch_one()
def fetch_one_scalar()
def execute()
def infer_sql_type(value) -> str
def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
datetime comes back as aretry re-dispatches the task after a failure, inside @workflow only.call_api, call_api#2, ...) andattempts (retries after the firstdelay (seconds before the firstmultiplier (applied to the delaymax_delay (ceiling inattempts is required, and an out-of-range or unknown key isattempts. Retries withdelay all go out in a single round.cache_ttl serves a previous result of the task for that many secondstask_script target is keyed on thetask_flow target,def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
retry takes the same policy as :func:task.def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
retry takes the same policy as :func:task.def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
step() todef workflow(func)
fn inline and checkpoint the result.fn.fn's result is encoded as JSON and decoded back before it is returned,datetime comes back as a string, a tuple as a list.fn runs in place and nothing isasync def step(name: str, fn)
seconds.asyncio.sleep.async def sleep(seconds: int)
key to name the step, then get_approval_urls(key) yields the URLsapproval, approval_2, ...value (form data), approver, and approved."minimal" shows approvers only the request (form and approve/reject){"markdown": "..."}.async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
fn(item), which should be a @task.concurrency (default: all at once).async def parallel(items, fn, *, concurrency: Optional[int] = None)
def commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None
まだレビューはありません。使ってみた感想をお寄せください。
ログインなしで書き込めます。使ってみた感想、質問、動かなかった環境などを気軽にどうぞ。
まだコメントはありません。最初のコメントを書いてみませんか。
Windmillに新しいTriggerCrud型のトリガーを追加する際、バックエンドからCLI、画面、同期やキャプチャ機能まで必要な変更箇所を確認できる開発チェックリストです。
WindmillのAIチャットを変更するときに、ツールやプロンプト、結果の返し方、コンテキスト量を整理し、評価で変更前後の挙動を比べるための指針です。
WindmillのAI生成モード向けに、実際の利用要求に沿ったベンチマークケースを作成・実行します。生成結果を決定的な検証とLLM評価で確認し、AIチャットやcopilotの変更前後を比較できます。
Windmill CLIのコマンドを使い、スクリプトやフローなどの管理、ジョブの実行履歴確認、失敗原因の調査を進めるためのリファレンスです。
モジュールのインターフェースを小さく保ちながら、内部に複雑な処理をまとめる設計を支援します。テストしやすさや変更のしやすさを考え、処理の境目を整理できます。
変更内容を確認し、対象ファイルだけを選んで、形式や長さの規則に沿ったConventional Commits形式のGitコミットを作成するスキルです。