diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fb3d763671..f991edac67 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.851.0" + ".": "1.853.0" } diff --git a/AGENTS.md b/AGENTS.md index 3dd08dea24..1d564d346a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,9 @@ Open-source platform for internal tools, workflows, API integrations, background - **Validation**: `docs/validation.md` — what checks to run based on what you changed - **Unreleased SDK changes**: `docs/wac-sdk-e2e.md` — exercising a client change on a real worker +- **Performance work**: `docs/perf-profiling.md` — the in-process profiler for the job path + (allocation bytes, allocation sites and SQL statements per job), how to A/B a change with it, and + its measurement caveats. A perf change ships with before/after numbers from it. - **Agent workers**: `docs/agent-worker-e2e.md` — building and running one locally. An agent reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain `cargo run`; a normal build cannot start one at all. diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc528d348..3e61ec61ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## [1.853.0](https://github.com/windmill-labs/windmill/compare/v1.852.0...v1.853.0) (2026-10-28) + + +### Features + +* agent to flow, tabbed agent page, one webhook panel with an AI prompt ([#11903](https://github.com/windmill-labs/windmill/issues/11903)) ([2e4c078](https://github.com/windmill-labs/windmill/commit/2e4c0789a25a51ec9fdb05ea6f57e1e70c73e591)) + + +### Performance Improvements + +* borrow cached script modules instead of cloning per job ([#12087](https://github.com/windmill-labs/windmill/issues/12087)) ([d03142b](https://github.com/windmill-labs/windmill/commit/d03142ba39f0ad2247ecd867da74890a7681cf85)) +* build log-mask automaton as a contiguous nfa ([#12088](https://github.com/windmill-labs/windmill/issues/12088)) ([97de072](https://github.com/windmill-labs/windmill/commit/97de072e7ff5f91d5b5494b78c409ce31bfde3b1)) + +## [1.852.0](https://github.com/windmill-labs/windmill/compare/v1.851.0...v1.852.0) (2026-10-28) + + +### Features + +* accept the google_service_account resource type alongside gcp_service_account ([#12070](https://github.com/windmill-labs/windmill/issues/12070)) ([e480501](https://github.com/windmill-labs/windmill/commit/e480501af9573c62bbec58d6edef5fc7ffb46433)) +* add ai review to drafts, draft diffs and session review ([#12063](https://github.com/windmill-labs/windmill/issues/12063)) ([77c0a26](https://github.com/windmill-labs/windmill/commit/77c0a26515bf4405b2e0918910aa8a84eb1a55ea)) +* gate guest apps by their mode and the protection rule alone ([#12061](https://github.com/windmill-labs/windmill/issues/12061)) ([d3869fc](https://github.com/windmill-labs/windmill/commit/d3869fcdb28a7053644582afe822f3d52d6c2ace)) + + +### Bug Fixes + +* check session admin rights in the session's workspace ([#12071](https://github.com/windmill-labs/windmill/issues/12071)) ([09ddf1e](https://github.com/windmill-labs/windmill/commit/09ddf1e0c76fc2a1e488dfdd3be147495c2c37f0)) +* **cli:** flow preview hub steps and windmill paths, trigger templates ([#12075](https://github.com/windmill-labs/windmill/issues/12075)) ([65f3c5e](https://github.com/windmill-labs/windmill/commit/65f3c5ee5cf0da6a22f770763cf77d5a7bff830d)) +* give GLM models a 32K output budget, including through Custom AI ([#12068](https://github.com/windmill-labs/windmill/issues/12068)) ([80a49f9](https://github.com/windmill-labs/windmill/commit/80a49f9bd3e8797f0f55aa9482e5ca9ec277cd07)) +* honor a step's timeout when it is the only step of a loop ([#12058](https://github.com/windmill-labs/windmill/issues/12058)) ([499c756](https://github.com/windmill-labs/windmill/commit/499c7569ffd49131cb6de9588b8691f062c5d91e)) +* keep a deleted session's data in its components until they unmount ([#12062](https://github.com/windmill-labs/windmill/issues/12062)) ([9c6c201](https://github.com/windmill-labs/windmill/commit/9c6c2018a4fa76cd080744e7f3defaa4bed04d74)) +* keep a new app's AI session unsaved until it is started ([#12065](https://github.com/windmill-labs/windmill/issues/12065)) ([f46b3a7](https://github.com/windmill-labs/windmill/commit/f46b3a7505158a35794f7ca186aed0e944a909da)) +* keep retry and branch settings intact when a flow is reserialized ([#12077](https://github.com/windmill-labs/windmill/issues/12077)) ([20def22](https://github.com/windmill-labs/windmill/commit/20def2289753320361ed6c7ab177f36880720499)) +* rebuild service log index without a hash dedupe that cannot spill ([#12080](https://github.com/windmill-labs/windmill/issues/12080)) ([26888e8](https://github.com/windmill-labs/windmill/commit/26888e834d5ac99d9af26a119bc233ab1bb95a0f)) +* scope rename's group membership update to the user's workspace ([#12043](https://github.com/windmill-labs/windmill/issues/12043)) ([ad571c6](https://github.com/windmill-labs/windmill/commit/ad571c62262dad9b2211f442d9e94c0a044ac167)) +* show not deployed yet for undeployed apps in session preview tabs ([#12076](https://github.com/windmill-labs/windmill/issues/12076)) ([bda59e6](https://github.com/windmill-labs/windmill/commit/bda59e6d23eb521b07d3769c54a59a80c7accd0f)) +* show the server's error when user offboarding fails ([#12069](https://github.com/windmill-labs/windmill/issues/12069)) ([7765913](https://github.com/windmill-labs/windmill/commit/77659130640c28a59f65989d83ab8be7f44e01c3)) +* wait for access before opening a draft-only item from the picker ([#12064](https://github.com/windmill-labs/windmill/issues/12064)) ([bfd31df](https://github.com/windmill-labs/windmill/commit/bfd31df5d5c96f6187ee5c4efb86a4d9caafafab)) + ## [1.851.0](https://github.com/windmill-labs/windmill/compare/v1.850.0...v1.851.0) (2026-10-28) diff --git a/backend/.sqlx/query-06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac.json b/backend/.sqlx/query-27aed1f0ee2c6f0ec4f2d7e64a67ce61a985c7c2eed72544acd05316bc002234.json similarity index 76% rename from backend/.sqlx/query-06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac.json rename to backend/.sqlx/query-27aed1f0ee2c6f0ec4f2d7e64a67ce61a985c7c2eed72544acd05316bc002234.json index 843207dad7..ac0e47054f 100644 --- a/backend/.sqlx/query-06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac.json +++ b/backend/.sqlx/query-27aed1f0ee2c6f0ec4f2d7e64a67ce61a985c7c2eed72544acd05316bc002234.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE jwt_entry AND day > CURRENT_DATE - 30)::INT AS \"guest_jwt_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ", + "query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE jwt_entry AND day > CURRENT_DATE - 30)::INT AS \"guest_jwt_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(DISTINCT a.workspace_id) FROM app a JOIN workspace w ON w.id = a.workspace_id\n WHERE a.policy->>'execution_mode' = 'guest' AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ", "describe": { "columns": [ { @@ -46,5 +46,5 @@ null ] }, - "hash": "06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac" + "hash": "27aed1f0ee2c6f0ec4f2d7e64a67ce61a985c7c2eed72544acd05316bc002234" } diff --git a/backend/.sqlx/query-2f0de5417088df098a200a67197155e10e909bd02c91082341dd73bb1d6d62e1.json b/backend/.sqlx/query-2f0de5417088df098a200a67197155e10e909bd02c91082341dd73bb1d6d62e1.json deleted file mode 100644 index 85f3b49b2d..0000000000 --- a/backend/.sqlx/query-2f0de5417088df098a200a67197155e10e909bd02c91082341dd73bb1d6d62e1.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE usr_to_group SET usr = $1 WHERE usr = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "2f0de5417088df098a200a67197155e10e909bd02c91082341dd73bb1d6d62e1" -} diff --git a/backend/.sqlx/query-3ab4e59067a97b74636320f6e237e9481881fd99c5be43a4e58ce95a3baefeb6.json b/backend/.sqlx/query-3ab4e59067a97b74636320f6e237e9481881fd99c5be43a4e58ce95a3baefeb6.json new file mode 100644 index 0000000000..82ebd7ab22 --- /dev/null +++ b/backend/.sqlx/query-3ab4e59067a97b74636320f6e237e9481881fd99c5be43a4e58ce95a3baefeb6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3ab4e59067a97b74636320f6e237e9481881fd99c5be43a4e58ce95a3baefeb6" +} diff --git a/backend/.sqlx/query-af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb.json b/backend/.sqlx/query-3b98b506c544162e630641147490539dc7e9c440cd1625054d6ee1b67a785418.json similarity index 92% rename from backend/.sqlx/query-af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb.json rename to backend/.sqlx/query-3b98b506c544162e630641147490539dc7e9c440cd1625054d6ee1b67a785418.json index c832fdeeda..fd43bec176 100644 --- a/backend/.sqlx/query-af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb.json +++ b/backend/.sqlx/query-3b98b506c544162e630641147490539dc7e9c440cd1625054d6ee1b67a785418.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n variable_expiration_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", + "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n variable_expiration_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", "describe": { "columns": [ { @@ -170,21 +170,16 @@ }, { "ordinal": 33, - "name": "guest_access_enabled", - "type_info": "Bool" - }, - { - "ordinal": 34, "name": "guest_jwt_public_key", "type_info": "Text" }, { - "ordinal": 35, + "ordinal": 34, "name": "guest_jwt_jwks_url", "type_info": "Text" }, { - "ordinal": 36, + "ordinal": 35, "name": "add_admins_and_developers_to_forks", "type_info": "Bool" } @@ -228,11 +223,10 @@ true, true, false, - false, true, true, false ] }, - "hash": "af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb" + "hash": "3b98b506c544162e630641147490539dc7e9c440cd1625054d6ee1b67a785418" } diff --git a/backend/.sqlx/query-76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477.json b/backend/.sqlx/query-76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477.json deleted file mode 100644 index ebd492abf3..0000000000 --- a/backend/.sqlx/query-76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Bool", - "Text" - ] - }, - "nullable": [] - }, - "hash": "76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477" -} diff --git a/backend/.sqlx/query-8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363.json b/backend/.sqlx/query-8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363.json deleted file mode 100644 index 7a822a8d6a..0000000000 --- a/backend/.sqlx/query-8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363.json +++ /dev/null @@ -1,232 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "workspace_id", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "slack_team_id", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "teams_team_id", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "teams_team_name", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "teams_team_guid", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "slack_name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "slack_command_script", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "teams_command_script", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "slack_email", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "slack_oauth_client_id", - "type_info": "Varchar" - }, - { - "ordinal": 10, - "name": "slack_oauth_client_secret", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "customer_id", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "plan", - "type_info": "Varchar" - }, - { - "ordinal": 13, - "name": "webhook", - "type_info": "Text" - }, - { - "ordinal": 14, - "name": "ai_config", - "type_info": "Jsonb" - }, - { - "ordinal": 15, - "name": "dbt_warehouses", - "type_info": "Jsonb" - }, - { - "ordinal": 16, - "name": "large_file_storage", - "type_info": "Jsonb" - }, - { - "ordinal": 17, - "name": "datatable", - "type_info": "Jsonb" - }, - { - "ordinal": 18, - "name": "ducklake", - "type_info": "Jsonb" - }, - { - "ordinal": 19, - "name": "git_sync", - "type_info": "Jsonb" - }, - { - "ordinal": 20, - "name": "deploy_ui", - "type_info": "Jsonb" - }, - { - "ordinal": 21, - "name": "default_app", - "type_info": "Varchar" - }, - { - "ordinal": 22, - "name": "default_scripts", - "type_info": "Jsonb" - }, - { - "ordinal": 23, - "name": "mute_critical_alerts", - "type_info": "Bool" - }, - { - "ordinal": 24, - "name": "color", - "type_info": "Varchar" - }, - { - "ordinal": 25, - "name": "operator_settings", - "type_info": "Jsonb" - }, - { - "ordinal": 26, - "name": "git_app_installations", - "type_info": "Jsonb" - }, - { - "ordinal": 27, - "name": "auto_invite", - "type_info": "Jsonb" - }, - { - "ordinal": 28, - "name": "error_handler", - "type_info": "Jsonb" - }, - { - "ordinal": 29, - "name": "success_handler", - "type_info": "Jsonb" - }, - { - "ordinal": 30, - "name": "public_app_execution_limit_per_minute", - "type_info": "Int4" - }, - { - "ordinal": 31, - "name": "error_handler_fallback_to_instance_alerts", - "type_info": "Bool" - }, - { - "ordinal": 32, - "name": "guest_access_enabled", - "type_info": "Bool" - }, - { - "ordinal": 33, - "name": "guest_jwt_public_key", - "type_info": "Text" - }, - { - "ordinal": 34, - "name": "guest_jwt_jwks_url", - "type_info": "Text" - }, - { - "ordinal": 35, - "name": "add_admins_and_developers_to_forks", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - true, - true, - true, - true, - true, - true, - true, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - false, - true, - true, - true, - true, - false, - false, - true, - true, - false - ] - }, - "hash": "8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363" -} diff --git a/backend/.sqlx/query-9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894.json b/backend/.sqlx/query-9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894.json deleted file mode 100644 index 6557494459..0000000000 --- a/backend/.sqlx/query-9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894" -} diff --git a/backend/.sqlx/query-af0bcc6aaf455f75cdf44a503eed708f200a52aaf55c7cf40f7b210f97688c15.json b/backend/.sqlx/query-af0bcc6aaf455f75cdf44a503eed708f200a52aaf55c7cf40f7b210f97688c15.json new file mode 100644 index 0000000000..4c04e5eb0e --- /dev/null +++ b/backend/.sqlx/query-af0bcc6aaf455f75cdf44a503eed708f200a52aaf55c7cf40f7b210f97688c15.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE usr_to_group SET usr = $1 WHERE usr = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "af0bcc6aaf455f75cdf44a503eed708f200a52aaf55c7cf40f7b210f97688c15" +} diff --git a/backend/.sqlx/query-e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a.json b/backend/.sqlx/query-d8b38538f21b533eccc147be0a24fdddbfdb63c021a4ec93c371c1ae38cb72be.json similarity index 76% rename from backend/.sqlx/query-e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a.json rename to backend/.sqlx/query-d8b38538f21b533eccc147be0a24fdddbfdb63c021a4ec93c371c1ae38cb72be.json index 7462224850..6bf69c90b1 100644 --- a/backend/.sqlx/query-e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a.json +++ b/backend/.sqlx/query-d8b38538f21b533eccc147be0a24fdddbfdb63c021a4ec93c371c1ae38cb72be.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", + "query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", "describe": { "columns": [ { @@ -40,26 +40,21 @@ }, { "ordinal": 7, - "name": "guest_access_enabled", - "type_info": "Bool" - }, - { - "ordinal": 8, "name": "add_admins_and_developers_to_forks", "type_info": "Bool" }, { - "ordinal": 9, + "ordinal": 8, "name": "deploy_ui", "type_info": "Jsonb" }, { - "ordinal": 10, + "ordinal": 9, "name": "large_file_storage", "type_info": "Jsonb" }, { - "ordinal": 11, + "ordinal": 10, "name": "datatable", "type_info": "Jsonb" } @@ -78,11 +73,10 @@ true, true, false, - false, true, true, true ] }, - "hash": "e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a" + "hash": "d8b38538f21b533eccc147be0a24fdddbfdb63c021a4ec93c371c1ae38cb72be" } diff --git a/backend/.sqlx/query-f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b.json b/backend/.sqlx/query-f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b.json deleted file mode 100644 index 3b0dbb2f41..0000000000 --- a/backend/.sqlx/query-f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b" -} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5787bbeec7..8a5b431d09 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1029,11 +1029,11 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.18.2" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" dependencies = [ - "aws-lc-sys 0.46.0", + "aws-lc-sys 0.47.0", "untrusted 0.7.1", "zeroize", ] @@ -1052,9 +1052,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" dependencies = [ "cc", "cmake", @@ -7808,9 +7808,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.30" +version = "1.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" +checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2" dependencies = [ "cc", "libc", @@ -15487,7 +15487,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-nats", @@ -15577,7 +15577,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.851.0" +version = "1.853.0" dependencies = [ "async-stream", "async-trait", @@ -15612,7 +15612,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -15625,7 +15625,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "argon2", @@ -15768,7 +15768,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -15792,7 +15792,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -15809,7 +15809,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "axum", @@ -15835,7 +15835,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.851.0" +version = "1.853.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15845,7 +15845,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -15862,7 +15862,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "base64 0.22.1", @@ -15884,7 +15884,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "axum", @@ -15907,7 +15907,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -15923,7 +15923,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -15945,7 +15945,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -15967,7 +15967,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -15981,7 +15981,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-nats", @@ -16017,7 +16017,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "axum", @@ -16043,7 +16043,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -16071,7 +16071,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "axum", @@ -16093,7 +16093,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -16113,7 +16113,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "axum", @@ -16155,7 +16155,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "axum", @@ -16185,7 +16185,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.851.0" +version = "1.853.0" dependencies = [ "lazy_static", "serde", @@ -16197,7 +16197,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.851.0" +version = "1.853.0" dependencies = [ "argon2", "axum", @@ -16221,7 +16221,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -16236,7 +16236,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.851.0" +version = "1.853.0" dependencies = [ "axum", "chrono", @@ -16275,7 +16275,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.851.0" +version = "1.853.0" dependencies = [ "chrono", "lazy_static", @@ -16290,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "axum", @@ -16309,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.851.0" +version = "1.853.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16427,7 +16427,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.851.0" +version = "1.853.0" dependencies = [ "chrono", "futures", @@ -16447,7 +16447,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.851.0" +version = "1.853.0" dependencies = [ "regex", "serde", @@ -16464,7 +16464,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16491,7 +16491,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "futures", @@ -16508,7 +16508,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.851.0" +version = "1.853.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16524,7 +16524,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -16546,7 +16546,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -16577,7 +16577,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "arc-swap", @@ -16602,7 +16602,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-stream", @@ -16638,7 +16638,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "futures", @@ -16657,7 +16657,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.851.0" +version = "1.853.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16666,7 +16666,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -16678,7 +16678,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "serde_json", @@ -16690,7 +16690,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "gosyn", @@ -16702,7 +16702,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -16714,7 +16714,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "serde_json", @@ -16726,7 +16726,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "nu-parser", @@ -16737,7 +16737,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16748,7 +16748,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16760,7 +16760,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16771,7 +16771,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-recursion", @@ -16794,7 +16794,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "serde_json", @@ -16806,7 +16806,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -16820,7 +16820,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16837,7 +16837,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -16850,7 +16850,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "serde", @@ -16862,7 +16862,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -16880,7 +16880,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16896,7 +16896,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16913,7 +16913,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -16927,7 +16927,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-recursion", @@ -16966,7 +16966,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "const_format", @@ -17004,7 +17004,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.851.0" +version = "1.853.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17015,7 +17015,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-recursion", @@ -17050,7 +17050,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17074,7 +17074,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17107,7 +17107,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17134,7 +17134,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17167,7 +17167,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17187,7 +17187,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17221,7 +17221,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17258,7 +17258,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17281,7 +17281,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17305,7 +17305,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-nats", @@ -17329,7 +17329,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17364,7 +17364,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17392,7 +17392,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-trait", @@ -17417,7 +17417,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17436,7 +17436,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-once-cell", @@ -17554,7 +17554,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.851.0" +version = "1.853.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3aa471a9c8..9be997528d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.851.0" +version = "1.853.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.851.0" +version = "1.853.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 6774ca240a..f4be79aa50 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -1443dd8902e3baf3271c089edfb31808f594ba6c +e7c9f267da4343dde009759d9c8330c795b9028f diff --git a/backend/migrations/20261028111747_close_inert_guest_apps.down.sql b/backend/migrations/20261028111747_close_inert_guest_apps.down.sql new file mode 100644 index 0000000000..6fe6281b43 --- /dev/null +++ b/backend/migrations/20261028111747_close_inert_guest_apps.down.sql @@ -0,0 +1,2 @@ +-- Which apps were moved to `publisher` is not recorded, so there is nothing to restore. +SELECT 1; diff --git a/backend/migrations/20261028111747_close_inert_guest_apps.up.sql b/backend/migrations/20261028111747_close_inert_guest_apps.up.sql new file mode 100644 index 0000000000..fbffb8f3df --- /dev/null +++ b/backend/migrations/20261028111747_close_inert_guest_apps.up.sql @@ -0,0 +1,15 @@ +-- Guest apps are now gated by their execution mode alone. An app stored as `guest` where the +-- workspace switch was off, or the instance switch was set, admitted no guest; leave it +-- admitting none by moving it to `publisher`, which runs the same way for members. +UPDATE app +SET policy = jsonb_set(policy, '{execution_mode}', '"publisher"') +FROM workspace_settings ws +WHERE ws.workspace_id = app.workspace_id + AND app.policy->>'execution_mode' = 'guest' + AND ( + NOT ws.guest_access_enabled + OR COALESCE( + (SELECT (value #>> '{}')::boolean FROM global_settings WHERE name = 'guest_access_disabled'), + false + ) + ); diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index cb5699ac8a..6f0ac9a93e 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.851.0" +version = "1.853.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.851.0" +version = "1.853.0" dependencies = [ "proc-macro2", "quote", @@ -6442,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.851.0" +version = "1.853.0" dependencies = [ "convert_case", "serde", @@ -6451,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "serde_json", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "gosyn", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "serde_json", @@ -6511,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "nu-parser", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "async-recursion", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "serde_json", @@ -6591,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -6605,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "convert_case", @@ -6622,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "serde", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6698,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "lazy_static", @@ -6744,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.851.0" +version = "1.853.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 8abf290093..9fc183e641 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.851.0" +version = "1.853.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/tests/app_guest_allowance.rs b/backend/tests/app_guest_allowance.rs index 88626b1c81..5c2e99f46f 100644 --- a/backend/tests/app_guest_allowance.rs +++ b/backend/tests/app_guest_allowance.rs @@ -57,13 +57,6 @@ async fn the_allowance_caps_strangers_and_meters_an_enterprise_plan( let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - authed( - client().post(format!("{ws}/workspaces/edit_guest_access")), - ADMIN_TOKEN, - ) - .json(&json!({ "guest_access_enabled": true })) - .send() - .await?; let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) .json(&json!({ "path": APP_PATH, diff --git a/backend/tests/app_guest_cloud_hosted.rs b/backend/tests/app_guest_cloud_hosted.rs index 7ab4145a65..1b59538aef 100644 --- a/backend/tests/app_guest_cloud_hosted.rs +++ b/backend/tests/app_guest_cloud_hosted.rs @@ -32,20 +32,6 @@ async fn the_cloud_admits_no_guest(db: Pool) -> anyhow::Result<()> { let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - // The workspace switch cannot be turned on, so no policy can lean on it. - let resp = authed( - client().post(format!("{ws}/workspaces/edit_guest_access")), - ADMIN_TOKEN, - ) - .json(&json!({ "guest_access_enabled": true })) - .send() - .await?; - assert_eq!(resp.status(), 400); - assert!( - resp.text().await?.contains("self-hosted"), - "the refusal must name what guests need" - ); - let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) .json(&json!({ "path": APP_PATH, @@ -56,6 +42,10 @@ async fn the_cloud_admits_no_guest(db: Pool) -> anyhow::Result<()> { .send() .await?; assert_eq!(resp.status(), 400, "an app cannot be deployed to guests"); + assert!( + resp.text().await?.contains("self-hosted"), + "the refusal must name what guests need" + ); // Nor can a key be configured for the JWT way in — the refusal lands before the // outbound JWKS fetch it would otherwise make. @@ -96,9 +86,6 @@ async fn the_cloud_admits_no_guest(db: Pool) -> anyhow::Result<()> { .bind(APP_PATH) .execute(&db) .await?; - sqlx::query("UPDATE workspace_settings SET guest_access_enabled = true WHERE workspace_id = 'test-workspace'") - .execute(&db) - .await?; // Deploying it again is not refused: only widening an app into guests is, so a // git-sync push of one already stored that way keeps working (and keeps being inert). diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs index 105458847a..f67757c6ed 100644 --- a/backend/tests/app_guest_execution_mode.rs +++ b/backend/tests/app_guest_execution_mode.rs @@ -35,19 +35,6 @@ fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuil builder.header("Authorization", format!("Bearer {}", token)) } -async fn enable_guests(port: u16, ws: &str) -> anyhow::Result<()> { - authed( - client().post(format!( - "http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_access" - )), - ADMIN_TOKEN, - ) - .json(&json!({ "guest_access_enabled": true })) - .send() - .await?; - Ok(()) -} - fn guest_scopes() -> Vec { vec![ "guest".to_string(), @@ -86,7 +73,6 @@ async fn guest_session_is_confined_to_its_app(db: Pool) -> anyhow::Res let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - enable_guests(port, "test-workspace").await?; insert_guest_token(&db, "test-workspace").await?; // Its own identity resolves, and reports the role rather than falling through to @@ -166,9 +152,7 @@ async fn guest_token_does_not_cross_workspaces(db: Pool) -> anyhow::Re } #[sqlx::test(fixtures("base"))] -async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch( - db: Pool, -) -> anyhow::Result<()> { +async fn guest_entry_follows_the_app_mode(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); @@ -194,25 +178,6 @@ async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch( .text() .await?; - // The app says guest, the workspace has not opted in: inert. - let resp = client() - .get(format!("{ws}/apps_u/guest_entry/{secret}")) - .send() - .await?; - assert_eq!( - resp.status(), - 404, - "a guest app in a workspace that has not enabled guests must not advertise entry" - ); - - authed( - client().post(format!("{ws}/workspaces/edit_guest_access")), - ADMIN_TOKEN, - ) - .json(&json!({ "guest_access_enabled": true })) - .send() - .await?; - // Unauthenticated on purpose: this is what a signed-out visitor reads. let resp = client() .get(format!("{ws}/apps_u/guest_entry/{secret}")) @@ -222,15 +187,15 @@ async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch( let entry: serde_json::Value = resp.json().await?; assert_eq!(entry["app_path"], json!(APP_PATH)); - // Turning the switch back off closes the door again even though the app's own - // policy is unchanged. - authed( - client().post(format!("{ws}/workspaces/edit_guest_access")), + // Back to members only: the door closes. + let resp = authed( + client().post(format!("{ws}/apps/update/{APP_PATH}")), ADMIN_TOKEN, ) - .json(&json!({ "guest_access_enabled": false })) + .json(&json!({ "policy": { "execution_mode": "publisher", "triggerables": {} } })) .send() .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); let resp = client() .get(format!("{ws}/apps_u/guest_entry/{secret}")) .send() @@ -238,7 +203,7 @@ async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch( assert_eq!( resp.status(), 404, - "turning guests off must stop advertising entry for an app already set to guest" + "an app taken out of guest mode must stop advertising entry" ); Ok(()) @@ -363,12 +328,11 @@ fn execute(port: u16, ws: &str, app: &str, token: &str) -> reqwest::RequestBuild })) } -/// The workspace switch is enforced at the auth door for every guest request, not -/// remembered per handler. This is what stands between a `guest` policy pushed by -/// git-sync and execution once an admin has turned guests off — and it closes the -/// app to sessions already issued. +/// Taking an app out of guest mode is what closes it to guests, sessions already issued +/// included: the mode is re-read on every run, not when the session was minted. This is +/// what stands between a guest and an app an admin has closed again. #[sqlx::test(fixtures("base"))] -async fn the_door_re_checks_the_workspace_switch(db: Pool) -> anyhow::Result<()> { +async fn leaving_guest_mode_closes_issued_sessions(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); @@ -381,38 +345,40 @@ async fn the_door_re_checks_the_workspace_switch(db: Pool) -> anyhow:: assert_eq!(resp.status(), 201, "{}", resp.text().await?); insert_guest_token(&db, "test-workspace").await?; - // Switch off: the session does not authenticate at all, even though the app's - // policy says guest and the session was (in this fixture) issued regardless. On - // the authed route that is a 401; on the optional-auth run route the rejected - // token reads as no token, and a guest-mode app then refuses the anonymous - // caller — a denial either way. - let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) - .send() - .await?; - assert_eq!( - resp.status(), - 401, - "a guest must not authenticate while guests are off" - ); - let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN) - .send() - .await?; - assert!( - resp.status().is_client_error() && resp.status() != 404, - "a guest must not run while guests are off, got {}", - resp.status() - ); - - // Switch on: through the door. What follows the run is the runnable lookup, - // which fails on the nonexistent script — the point is that it is no longer a - // denial. - enable_guests(port, "test-workspace").await?; + // In guest mode: through the door. What follows the run is the runnable lookup, + // which fails on the nonexistent script; the point is that it is not a denial. let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN) .send() .await?; assert!( resp.status() != 401 && resp.status() != 403, - "with guests on, the door must let the run through: {}", + "a guest must reach its guest-mode app: {}", + resp.status() + ); + + let resp = authed( + client().post(format!("{ws}/apps/update/{APP_PATH}")), + ADMIN_TOKEN, + ) + .json(&app_with_runnable(APP_PATH, "publisher", false)) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + // Evict the cached policy as the server's notify poller would. + for e in windmill_common::notify_events::poll_notify_events(&db, 0).await? { + if e.channel == "notify_app_policy_change" { + if let Some((ws, path)) = e.payload.split_once(':') { + windmill_api::invalidate_app_policy_cache(ws, path); + } + } + } + + let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN) + .send() + .await?; + assert!( + resp.status() == 401 || resp.status() == 403, + "an issued guest session must not run an app taken out of guest mode: {}", resp.status() ); @@ -428,13 +394,6 @@ async fn guest_cannot_run_another_guest_app(db: Pool) -> anyhow::Resul let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - authed( - client().post(format!("{ws}/workspaces/edit_guest_access")), - ADMIN_TOKEN, - ) - .json(&json!({ "guest_access_enabled": true })) - .send() - .await?; let other = "u/test-user/other_guest_app"; let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) .json(&guest_app_with_runnable(other, false)) @@ -510,7 +469,6 @@ async fn a_guest_cannot_read_a_job_it_did_not_launch(db: Pool) -> anyh let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - enable_guests(port, "test-workspace").await?; insert_guest_token(&db, "test-workspace").await?; let resp = authed(client().post(format!("{ws}/scripts/create")), ADMIN_TOKEN) .json(&json!({ @@ -611,15 +569,14 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result Ok(()) } -/// Renaming a workspace copies its settings; the guest switch and the guest JWT key must -/// travel with them, or the rename silently shuts every guest app or drops the key. +/// Renaming a workspace copies its settings; the guest JWT key must travel with them, +/// or the rename silently drops the key. #[sqlx::test(fixtures("base"))] -async fn a_workspace_rename_keeps_the_guest_switch(db: Pool) -> anyhow::Result<()> { +async fn a_workspace_rename_keeps_the_guest_jwt_key(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); - enable_guests(port, "test-workspace").await?; sqlx::query( "INSERT INTO guest_activity (email, workspace_id, day) VALUES ('guest@example.com', 'test-workspace', CURRENT_DATE)", @@ -641,12 +598,6 @@ async fn a_workspace_rename_keeps_the_guest_switch(db: Pool) -> anyhow .send() .await?; assert_eq!(resp.status(), 200, "{}", resp.text().await?); - let enabled: bool = sqlx::query_scalar( - "SELECT guest_access_enabled FROM workspace_settings WHERE workspace_id = 'test-workspace-2'", - ) - .fetch_one(&db) - .await?; - assert!(enabled, "the guest switch travels with the workspace"); let jwt_key: Option = sqlx::query_scalar( "SELECT guest_jwt_public_key FROM workspace_settings WHERE workspace_id = 'test-workspace-2'", ) @@ -668,70 +619,6 @@ async fn a_workspace_rename_keeps_the_guest_switch(db: Pool) -> anyhow Ok(()) } -/// The superadmin switch sits above every workspace's: off, no guest session stands and -/// no app discovers as open, whatever the workspace and the app say. -#[sqlx::test(fixtures("base"))] -async fn the_instance_switch_closes_every_workspace(db: Pool) -> anyhow::Result<()> { - initialize_tracing().await; - let server = ApiServer::start(db.clone()).await?; - let port = server.addr.port(); - let ws = format!("http://localhost:{port}/api/w/test-workspace"); - - enable_guests(port, "test-workspace").await?; - let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) - .json(&guest_app_with_runnable(APP_PATH, false)) - .send() - .await?; - assert_eq!(resp.status(), 201, "{}", resp.text().await?); - insert_guest_token(&db, "test-workspace").await?; - let set_instance_switch = |disabled: bool| { - authed( - client().post(format!( - "http://localhost:{port}/api/settings/global/guest_access_disabled" - )), - ADMIN_TOKEN, - ) - .json(&json!({ "value": disabled })) - .send() - }; - - let secret: String = authed( - client().get(format!("{ws}/apps/secret_of/{APP_PATH}")), - ADMIN_TOKEN, - ) - .send() - .await? - .text() - .await?; - - set_instance_switch(true).await?.error_for_status()?; - let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) - .send() - .await?; - assert_eq!( - resp.status(), - 401, - "the instance switch closes an issued session" - ); - let resp = client() - .get(format!("{ws}/apps_u/guest_entry/{secret}")) - .send() - .await?; - assert_eq!( - resp.status(), - 404, - "and nothing discovers as open to guests" - ); - - set_instance_switch(false).await?.error_for_status()?; - let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) - .send() - .await?; - assert_eq!(resp.status(), 200, "back on, the session stands again"); - - Ok(()) -} - /// An account holder is never a guest, and that holds after the mint too: a session /// minted before the account existed ends at the door the moment one does, so an /// account provisioned in a race with the mint cannot outlive the rule. @@ -742,7 +629,6 @@ async fn an_account_ends_the_guest_session(db: Pool) -> anyhow::Result let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - enable_guests(port, "test-workspace").await?; insert_guest_token(&db, "test-workspace").await?; let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) .send() @@ -778,7 +664,6 @@ async fn a_guest_cannot_upload_outside_a_policy(db: Pool) -> anyhow::R let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - enable_guests(port, "test-workspace").await?; let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) .json(&guest_app_with_runnable(APP_PATH, false)) // no `s3_inputs` .send() @@ -825,7 +710,6 @@ async fn a_guest_uses_an_anonymous_app_as_itself(db: Pool) -> anyhow:: let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - enable_guests(port, "test-workspace").await?; let resp = authed(client().post(format!("{ws}/scripts/create")), ADMIN_TOKEN) .json(&json!({ "path": "u/test-user/noop", @@ -878,7 +762,6 @@ async fn a_guest_minted_embed_token_stays_a_guest(db: Pool) -> anyhow: let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - enable_guests(port, "test-workspace").await?; let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) .json(&guest_app_with_runnable(APP_PATH, true)) .send() @@ -937,13 +820,13 @@ async fn a_guest_minted_embed_token_stays_a_guest(db: Pool) -> anyhow: let me: serde_json::Value = resp.json().await?; assert_eq!(me["role"], json!("guest")); - // Governed: the workspace switch closes it at the door, iframe or not. - authed( - client().post(format!("{ws}/workspaces/edit_guest_access")), - ADMIN_TOKEN, + // Governed: the door's no-account rule closes it, iframe or not, once the guest's + // email is promoted to an account. + sqlx::query( + "INSERT INTO password (email, password_hash, login_type, super_admin, verified, name) + VALUES ('guest@example.com', 'not-a-real-hash', 'password', false, true, 'Guest')", ) - .json(&json!({ "guest_access_enabled": false })) - .send() + .execute(&db) .await?; let resp = authed(client().get(format!("{ws}/users/whoami")), &embed) .send() @@ -951,7 +834,7 @@ async fn a_guest_minted_embed_token_stays_a_guest(db: Pool) -> anyhow: assert_eq!( resp.status(), 401, - "turning guests off must stop a guest's embed token authenticating" + "an account for the guest's email must stop its embed token authenticating" ); let resp = execute(port, "test-workspace", APP_PATH, &embed) .send() @@ -961,7 +844,6 @@ async fn a_guest_minted_embed_token_stays_a_guest(db: Pool) -> anyhow: "and running components, got {}", resp.status() ); - enable_guests(port, "test-workspace").await?; // And its scopes are not something the guest's email can later rewrite. The // guest session itself cannot reach `/users/*` (workspace pin), so model the real @@ -1006,7 +888,6 @@ async fn a_guest_label_is_governed_without_the_sentinel(db: Pool) -> a let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - enable_guests(port, "test-workspace").await?; let scopes: Vec = guest_scopes() .into_iter() .filter(|s| s != "guest") @@ -1051,13 +932,6 @@ async fn a_disabled_account_cannot_become_a_guest(db: Pool) -> anyhow: let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - authed( - client().post(format!("{ws}/workspaces/edit_guest_access")), - ADMIN_TOKEN, - ) - .json(&json!({ "guest_access_enabled": true })) - .send() - .await?; let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) .json(&guest_app_with_runnable(APP_PATH, false)) .send() diff --git a/backend/tests/app_guest_jwt_allowance.rs b/backend/tests/app_guest_jwt_allowance.rs index 85cb51d39e..c6f929776c 100644 --- a/backend/tests/app_guest_jwt_allowance.rs +++ b/backend/tests/app_guest_jwt_allowance.rs @@ -67,13 +67,6 @@ async fn a_guest_jwt_is_capped_like_a_signed_in_guest(db: Pool) -> any let port = server.addr.port(); let ws = format!("http://localhost:{port}/api/w/test-workspace"); - authed( - client().post(format!("{ws}/workspaces/edit_guest_access")), - ADMIN_TOKEN, - ) - .json(&json!({ "guest_access_enabled": true })) - .send() - .await?; let resp = authed( client().post(format!("{ws}/workspaces/edit_guest_jwt_key")), ADMIN_TOKEN, @@ -109,7 +102,11 @@ async fn a_guest_jwt_is_capped_like_a_signed_in_guest(db: Pool) -> any ) .send() .await?; - assert_eq!(resp.status(), 401, "a stranger's JWT is refused past the cap"); + assert_eq!( + resp.status(), + 401, + "a stranger's JWT is refused past the cap" + ); let resp = authed( client().get(format!("{ws}/users/whoami")), diff --git a/backend/tests/app_guest_jwt_entry.rs b/backend/tests/app_guest_jwt_entry.rs index 7b84130a04..e196c914e4 100644 --- a/backend/tests/app_guest_jwt_entry.rs +++ b/backend/tests/app_guest_jwt_entry.rs @@ -85,20 +85,6 @@ fn bearer(claims: &Claims, priv_pem: &str, alg: Algorithm) -> String { format!("jwt_guest_{jwt}") } -async fn enable_guests(port: u16, ws: &str, on: bool) -> anyhow::Result<()> { - let resp = authed( - client().post(format!( - "http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_access" - )), - ADMIN_TOKEN, - ) - .json(&json!({ "guest_access_enabled": on })) - .send() - .await?; - assert_eq!(resp.status(), 200, "{}", resp.text().await?); - Ok(()) -} - async fn set_guest_jwt_pem(port: u16, ws: &str, pem: &str) -> anyhow::Result<()> { let resp = authed( client().post(format!( @@ -157,7 +143,6 @@ async fn a_valid_guest_jwt_opens_its_app(db: Pool) -> anyhow::Result<( let port = server.addr.port(); let ws = "test-workspace"; - enable_guests(port, ws, true).await?; set_guest_jwt_pem(port, ws, PUB1).await?; let resp = authed( client().post(format!("http://localhost:{port}/api/w/{ws}/scripts/create")), @@ -239,7 +224,6 @@ async fn guest_jwt_refusals(db: Pool) -> anyhow::Result<()> { let port = server.addr.port(); let ws = "test-workspace"; - enable_guests(port, ws, true).await?; set_guest_jwt_pem(port, ws, PUB1).await?; create_app(port, ws, app(APP_PATH, "guest", false)).await?; create_app(port, ws, app("u/test-user/members_app", "publisher", false)).await?; @@ -346,48 +330,6 @@ async fn guest_jwt_refusals(db: Pool) -> anyhow::Result<()> { Ok(()) } -/// The workspace switch gates a JWT guest exactly as it gates a signed-in one, at the -/// auth door, so turning guests off closes the JWT entry too. -#[sqlx::test(fixtures("base"))] -async fn guest_jwt_needs_the_workspace_switch(db: Pool) -> anyhow::Result<()> { - initialize_tracing().await; - let server = ApiServer::start(db.clone()).await?; - let port = server.addr.port(); - let ws = "test-workspace"; - - set_guest_jwt_pem(port, ws, PUB1).await?; - create_app(port, ws, app(APP_PATH, "guest", false)).await?; - let token = bearer(&Claims::valid(), PRIV1, Algorithm::ES256); - - // Switch off (the default): refused. - let resp = whoami(port, ws, &token).send().await?; - assert_eq!( - resp.status(), - 401, - "a JWT guest must be refused while guests are off" - ); - - // Switch on: through. - enable_guests(port, ws, true).await?; - let resp = whoami(port, ws, &token).send().await?; - assert_eq!( - resp.status(), - 200, - "with guests on, the JWT guest is admitted" - ); - - // Off again: closed on the next request. - enable_guests(port, ws, false).await?; - let resp = whoami(port, ws, &token).send().await?; - assert_eq!( - resp.status(), - 401, - "turning guests off closes the JWT guest again" - ); - - Ok(()) -} - /// A guest JWT is pinned to the workspace its claim names, so it authenticates on no /// workspace-less route: the arm has no workspace to check the claim against. #[sqlx::test(fixtures("base"))] @@ -397,7 +339,6 @@ async fn guest_jwt_rejected_on_workspaceless_route(db: Pool) -> anyhow let port = server.addr.port(); let ws = "test-workspace"; - enable_guests(port, ws, true).await?; set_guest_jwt_pem(port, ws, PUB1).await?; create_app(port, ws, app(APP_PATH, "guest", false)).await?; let token = bearer(&Claims::valid(), PRIV1, Algorithm::ES256); @@ -427,7 +368,6 @@ async fn a_guest_jwt_derived_embed_token_is_capped(db: Pool) -> anyhow let port = server.addr.port(); let ws = "test-workspace"; - enable_guests(port, ws, true).await?; set_guest_jwt_pem(port, ws, PUB1).await?; create_app(port, ws, app(APP_PATH, "guest", true)).await?; let secret: String = authed( diff --git a/backend/tests/perf_profile.rs b/backend/tests/perf_profile.rs new file mode 100644 index 0000000000..d6dcad05d2 --- /dev/null +++ b/backend/tests/perf_profile.rs @@ -0,0 +1,601 @@ +//! Per-job cost profiler: allocation bytes, allocation sites and SQL statements per job, for +//! jobs run end to end through the real queue, worker and database in this process. +//! +//! Not a regression test: every test is `#[ignore]`d and asserts only that the jobs succeeded. +//! How to run it, read it and A/B a change: `docs/perf-profiling.md`. +//! +//! Each workload warms up, measures the idle background rate, pushes `PERF_N` jobs scheduled a +//! few seconds ahead (so the push is measured alone), then measures the worker until all of them +//! complete. Reports land in `$PERF_OUT/.txt`. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering::Relaxed}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use futures::StreamExt; +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::flows::FlowValue; +use windmill_common::jobs::JobPayload; +use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings}; +use windmill_common::scripts::{ScriptHash, ScriptLang}; +use windmill_common::worker::Connection; +use windmill_test_utils::*; + +// ---------------------------------------------------------------- allocator + +struct Profiler; + +#[global_allocator] +static GLOBAL: Profiler = Profiler; + +static BYTES: AtomicU64 = AtomicU64::new(0); +static COUNT: AtomicU64 = AtomicU64::new(0); +static SAMPLING: AtomicBool = AtomicBool::new(false); + +const BYTE_PERIOD: u64 = 128 * 1024; +const COUNT_PERIOD: u64 = 2048; + +thread_local! { + // Set while a sample is being captured: the backtrace's own allocations are neither + // counted nor sampled, or they would dominate every number. + static IN_SAMPLER: Cell = const { Cell::new(false) }; + static BYTES_LEFT: Cell = const { Cell::new(BYTE_PERIOD) }; + static COUNT_LEFT: Cell = const { Cell::new(COUNT_PERIOD) }; +} + +#[derive(Default, Clone, Copy)] +struct Site { + byte_samples: u64, + count_samples: u64, +} + +static SITES_LEAF: Mutex>> = Mutex::new(None); +static SITES_CHAIN: Mutex>> = Mutex::new(None); + +unsafe impl GlobalAlloc for Profiler { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + record(l.size()); + System.alloc(l) + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + record(l.size()); + System.alloc_zeroed(l) + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, n: usize) -> *mut u8 { + record(n); + System.realloc(p, l, n) + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + System.dealloc(p, l) + } +} + +#[inline] +fn record(size: usize) { + if IN_SAMPLER.try_with(|i| i.get()).unwrap_or(true) { + return; + } + BYTES.fetch_add(size as u64, Relaxed); + COUNT.fetch_add(1, Relaxed); + if !SAMPLING.load(Relaxed) { + return; + } + let size = size as u64; + let byte_hits = BYTES_LEFT.with(|b| { + let left = b.get(); + if size >= left { + let over = size - left; + b.set(BYTE_PERIOD - over % BYTE_PERIOD); + 1 + over / BYTE_PERIOD + } else { + b.set(left - size); + 0 + } + }); + let count_hit = COUNT_LEFT.with(|c| { + let left = c.get(); + c.set(if left <= 1 { COUNT_PERIOD } else { left - 1 }); + left <= 1 + }); + if byte_hits > 0 || count_hit { + IN_SAMPLER.with(|i| i.set(true)); + capture(byte_hits, count_hit); + IN_SAMPLER.with(|i| i.set(false)); + } +} + +/// Attributes a sample to the deepest frame in a Windmill crate. Dev builds use split DWARF and +/// inline aggressively, so frame *names* are often bare (`poll<…>`); the relative +/// `./windmill-*/src/…:line` location is what identifies the site. +fn capture(byte_hits: u64, count_hit: bool) { + let bt = std::backtrace::Backtrace::force_capture().to_string(); + let mut frames: Vec<(String, String)> = vec![]; + let mut lines = bt.lines().peekable(); + while let Some(l) = lines.next() { + let Some((num, name)) = l.trim_start().split_once(": ") else { + continue; + }; + if !num.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let loc = match lines.peek() { + Some(n) if n.trim_start().starts_with("at ") => { + lines.next().unwrap().trim_start()[3..].to_string() + } + _ => String::new(), + }; + frames.push((name.to_string(), loc)); + } + let is_windmill = |(_, loc): &(String, String)| { + (loc.starts_with("./windmill-") || loc.starts_with("./src/")) + && !loc.starts_with("./windmill-test-utils") + }; + let Some(first) = frames.iter().position(is_windmill) else { + return; + }; + let short_loc = |l: &str| -> String { + let l = l.trim_start_matches("./"); + l.rfind(':').map_or(l, |i| &l[..i]).to_string() + }; + const PLUMBING: &[&str] = &[ + "alloc::", + " {callee}]", short_loc(&frames[first].1)); + let chain = frames[first..] + .iter() + .filter(|f| is_windmill(f)) + .map(|f| short_loc(&f.1)) + .take(4) + .collect::>() + .join(" < "); + let chain = format!("{chain} [-> {callee}]"); + for (map, key) in [(&SITES_LEAF, leaf), (&SITES_CHAIN, chain)] { + let mut g = map.lock().unwrap(); + let e = g.get_or_insert_with(HashMap::new).entry(key).or_default(); + e.byte_samples += byte_hits; + e.count_samples += count_hit as u64; + } +} + +// ---------------------------------------------------------------- sql counting + +static SQL_ON: AtomicBool = AtomicBool::new(false); +static SQL: Mutex>> = Mutex::new(None); + +struct SqlLayer; + +#[derive(Default)] +struct SqlVisit { + statement: String, + summary: String, + secs: f64, +} + +impl tracing::field::Visit for SqlVisit { + fn record_str(&mut self, f: &tracing::field::Field, v: &str) { + match f.name() { + "db.statement" => self.statement = v.to_string(), + "summary" => self.summary = v.to_string(), + _ => {} + } + } + fn record_f64(&mut self, f: &tracing::field::Field, v: f64) { + if f.name() == "elapsed_secs" { + self.secs = v; + } + } + fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) { + match f.name() { + "db.statement" => self.statement = format!("{v:?}"), + "summary" => self.summary = format!("{v:?}"), + _ => {} + } + } +} + +impl tracing_subscriber::Layer for SqlLayer { + fn on_event(&self, ev: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) { + if !SQL_ON.load(Relaxed) { + return; + } + let mut v = SqlVisit::default(); + ev.record(&mut v); + // sqlx leaves `db.statement` empty when the summary already is the whole statement + let raw = if v.statement.trim().is_empty() { + v.summary + } else { + v.statement + }; + let key: String = raw + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(200) + .collect(); + let mut g = SQL.lock().unwrap(); + let e = g.get_or_insert_with(HashMap::new).entry(key).or_default(); + e.0 += 1; + e.1 += v.secs; + } +} + +/// SQL counting is a separate mode (`PERF_SQL=1`): sqlx formats a query summary for every +/// statement once its event is enabled, which inflates the allocation numbers. +fn sql_mode() -> bool { + std::env::var("PERF_SQL").is_ok_and(|v| !v.is_empty() && v != "0") +} + +fn init_sql_counting() { + use tracing_subscriber::prelude::*; + if !sql_mode() { + return; + } + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let filter = tracing_subscriber::filter::Targets::new() + .with_target("sqlx::query", tracing::Level::TRACE); + let _ = tracing::subscriber::set_global_default( + tracing_subscriber::registry().with(SqlLayer.with_filter(filter)), + ); + }); +} + +// ---------------------------------------------------------------- measurement + +struct Snap { + bytes: u64, + count: u64, + at: Instant, +} + +fn start_phase() -> Snap { + *SITES_LEAF.lock().unwrap() = None; + *SITES_CHAIN.lock().unwrap() = None; + *SQL.lock().unwrap() = None; + SAMPLING.store(true, Relaxed); + SQL_ON.store(true, Relaxed); + Snap { bytes: BYTES.load(Relaxed), count: COUNT.load(Relaxed), at: Instant::now() } +} + +fn end_phase() -> Snap { + let snap = Snap { bytes: BYTES.load(Relaxed), count: COUNT.load(Relaxed), at: Instant::now() }; + SAMPLING.store(false, Relaxed); + SQL_ON.store(false, Relaxed); + snap +} + +struct Idle { + bytes_per_sec: f64, + count_per_sec: f64, +} + +fn report(out: &mut String, title: &str, s0: &Snap, s1: &Snap, n: u64, idle: &Idle) { + use std::fmt::Write; + let secs = s1.at.duration_since(s0.at).as_secs_f64(); + let n_f = n as f64; + let bytes = (s1.bytes - s0.bytes) as f64; + let count = (s1.count - s0.count) as f64; + let adj_bytes = (bytes - idle.bytes_per_sec * secs).max(0.0); + let adj_count = (count - idle.count_per_sec * secs).max(0.0); + // Allocation numbers from SQL mode are inflated by sqlx's query summaries; the mode tag + // keeps them from being mistaken for an allocation run. + let mode = if sql_mode() { + "sql mode, allocs not comparable" + } else { + "alloc mode" + }; + let _ = writeln!( + out, + "\n=== {title} [{mode}]: {n} jobs in {secs:.2}s | raw {:.1} KiB/job {:.0} allocs/job | idle-adjusted {:.1} KiB/job {:.0} allocs/job", + bytes / n_f / 1024.0, + count / n_f, + adj_bytes / n_f / 1024.0, + adj_count / n_f + ); + for (name, map) in [("LEAF", &SITES_LEAF), ("CHAIN", &SITES_CHAIN)] { + let g = map.lock().unwrap(); + let Some(map) = g.as_ref() else { continue }; + let mut sites: Vec<_> = map.iter().map(|(k, s)| (k.clone(), *s)).collect(); + sites.sort_by_key(|(_, s)| std::cmp::Reverse(s.byte_samples)); + let _ = writeln!(out, "--- {name} by bytes (KiB/job est.)"); + for (k, s) in sites.iter().take(30).filter(|(_, s)| s.byte_samples > 0) { + let kib = (s.byte_samples * BYTE_PERIOD) as f64 / n_f / 1024.0; + let _ = writeln!(out, "{kib:>9.1} {k}"); + } + sites.sort_by_key(|(_, s)| std::cmp::Reverse(s.count_samples)); + let _ = writeln!(out, "--- {name} by alloc count (allocs/job est.)"); + for (k, s) in sites.iter().take(30).filter(|(_, s)| s.count_samples > 0) { + let allocs = (s.count_samples * COUNT_PERIOD) as f64 / n_f; + let _ = writeln!(out, "{allocs:>9.0} {k}"); + } + } + let g = SQL.lock().unwrap(); + if let Some(map) = g.as_ref() { + let mut stmts: Vec<_> = map.iter().collect(); + stmts.sort_by(|a, b| b.1 .0.cmp(&a.1 .0)); + let total: u64 = stmts.iter().map(|(_, (c, _))| c).sum(); + let total_secs: f64 = stmts.iter().map(|(_, (_, s))| s).sum(); + let _ = writeln!( + out, + "--- SQL: {:.1} statements/job, {:.2} ms/job db time", + total as f64 / n_f, + total_secs * 1000.0 / n_f + ); + for (k, (c, s)) in stmts.iter().take(60) { + let _ = writeln!( + out, + "{:>7.2}/job {:>7.3}ms/job {k}", + *c as f64 / n_f, + s * 1000.0 / n_f + ); + } + } +} + +async fn wait_all( + completed: &mut (impl futures::Stream + Unpin), + ids: &HashSet, +) { + let mut left = ids.clone(); + while !left.is_empty() { + let id = tokio::time::timeout(Duration::from_secs(120), completed.next()) + .await + .expect("timed out waiting for completions") + .expect("completion stream ended"); + left.remove(&id); + } +} + +async fn assert_succeeded(db: &Pool, ids: impl IntoIterator) { + for id in ids { + let job = completed_job(*id, db).await; + assert!(job.success, "job {id} failed: {:?}", job.json_result()); + } +} + +async fn profile_workload(db: Pool, name: &str, payload: impl Fn() -> JobPayload) { + let n: usize = std::env::var("PERF_N") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(30); + // `capture` takes std's non-reentrant backtrace lock. With either variable enabled, any + // `anyhow::Error` captures a backtrace too, and an allocation it makes under that lock can + // land on a sample point and deadlock the thread. + for var in ["RUST_BACKTRACE", "RUST_LIB_BACKTRACE"] { + let enabled = std::env::var(var).is_ok_and(|v| v != "0"); + assert!( + !enabled, + "unset {var}: it can deadlock the allocation sampler" + ); + } + init_sql_counting(); + set_jwt_secret().await; + let server = ApiServer::start(db.clone()).await.unwrap(); + let mut completed = listen_for_completed_jobs(&db).await; + let (quit, worker) = spawn_test_worker(&Connection::Sql(db.clone()), server.addr.port()); + + // Warm caches (script/flow lookups, runtime caches) so they do not count against the + // measured jobs. + let mut warm = HashSet::new(); + for _ in 0..3 { + warm.insert(RunJob::from(payload()).push(&db).await); + } + wait_all(&mut completed, &warm).await; + assert_succeeded(&db, &warm).await; + + let mut out = String::new(); + + let i0 = start_phase(); + tokio::time::sleep(Duration::from_secs(4)).await; + let i1 = end_phase(); + let idle_secs = i1.at.duration_since(i0.at).as_secs_f64(); + let idle = Idle { + bytes_per_sec: (i1.bytes - i0.bytes) as f64 / idle_secs, + count_per_sec: (i1.count - i0.count) as f64 / idle_secs, + }; + let no_idle = Idle { bytes_per_sec: 0.0, count_per_sec: 0.0 }; + report( + &mut out, + &format!("{name} IDLE (per second)"), + &i0, + &i1, + idle_secs.round() as u64, + &no_idle, + ); + + // Scheduled ahead so the worker cannot start on them while the push is being measured. + let run_at = chrono::Utc::now() + chrono::Duration::seconds(12); + let p0 = start_phase(); + let mut ids = HashSet::new(); + for _ in 0..n { + ids.insert( + RunJob::from(payload()) + .push_arg_scheduled_for_o(Some(run_at)) + .push(&db) + .await, + ); + } + let p1 = end_phase(); + report(&mut out, &format!("{name} PUSH"), &p0, &p1, n as u64, &idle); + let wait = (run_at - chrono::Utc::now()) + .to_std() + .expect("the push phase outlasted the schedule offset; lower PERF_N"); + tokio::time::sleep(wait).await; + + let w0 = start_phase(); + wait_all(&mut completed, &ids).await; + let w1 = end_phase(); + report( + &mut out, + &format!("{name} WORKER"), + &w0, + &w1, + n as u64, + &idle, + ); + assert_succeeded(&db, &ids).await; + + quit.send(); + let _ = tokio::time::timeout(Duration::from_secs(30), worker).await; + let _ = server.close().await; + + let dir: std::path::PathBuf = + std::env::var("PERF_OUT").map_or_else(|_| std::env::temp_dir(), Into::into); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(format!("{name}.txt")), &out).unwrap(); + println!("{out}"); +} + +async fn insert_script( + db: &Pool, + path: &str, + hash: i64, + lang: &str, + content: &str, + lock: Option<&str>, +) { + sqlx::query( + "INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) + VALUES ('test-workspace', 'test-user', $1, '{}', '', '', $2, $3, $4::script_lang, $5)", + ) + .bind(content) + .bind(path) + .bind(hash) + .bind(lang) + .bind(lock) + .execute(db) + .await + .unwrap(); +} + +fn script_hash_payload(path: &str, hash: i64, language: ScriptLang) -> JobPayload { + JobPayload::ScriptHash { + hash: ScriptHash(hash), + path: path.to_string(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + language, + priority: None, + apply_preprocessor: false, + concurrency_settings: ConcurrencySettings::default().into(), + debouncing_settings: DebouncingSettings::default(), + labels: None, + job_token_scopes: None, + } +} + +async fn insert_flow(db: &Pool, path: &str, version: i64, value: serde_json::Value) { + let _: FlowValue = serde_json::from_value(value.clone()).expect("invalid flow value"); + sqlx::query( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms, versions) + VALUES ('test-workspace', $1, '', '', $2, 'test-user', NOW(), '{}', '{}', ARRAY[$3::bigint])", + ) + .bind(path) + .bind(&value) + .bind(version) + .execute(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by, created_at) + VALUES ($1, 'test-workspace', $2, $3, '{}', 'test-user', NOW())", + ) + .bind(version) + .bind(path) + .bind(&value) + .execute(db) + .await + .unwrap(); +} + +fn flow_payload(path: &str, version: i64) -> JobPayload { + JobPayload::Flow { + path: path.to_string(), + dedicated_worker: None, + apply_preprocessor: false, + version, + labels: None, + job_token_scopes: None, + } +} + +// ---------------------------------------------------------------- workloads + +#[sqlx::test(fixtures("base"))] +#[ignore = "profiling harness, see docs/perf-profiling.md"] +async fn perf_bash_hash(db: Pool) { + insert_script(&db, "f/perf/bash", 900001, "bash", "echo 42\n", Some("")).await; + profile_workload(db, "bash_hash", || { + script_hash_payload("f/perf/bash", 900001, ScriptLang::Bash) + }) + .await; +} + +#[sqlx::test(fixtures("base"))] +#[ignore = "profiling harness, see docs/perf-profiling.md"] +async fn perf_bun_hash(db: Pool) { + // An empty bun lock is rejected at run time ("expected //bun.lock split pattern"). + let content = "export function main() { return { a: 1, b: [1, 2, 3] }; }\n"; + insert_script(&db, "f/perf/bun", 900002, "bun", content, None).await; + profile_workload(db, "bun_hash", || { + script_hash_payload("f/perf/bun", 900002, ScriptLang::Bun) + }) + .await; +} + +#[sqlx::test(fixtures("base"))] +#[ignore = "profiling harness, see docs/perf-profiling.md"] +async fn perf_flow_identity(db: Pool) { + let step = |id: &str| json!({"id": id, "value": {"type": "identity"}}); + let value = json!({"modules": [step("a"), step("b"), step("c")]}); + insert_flow(&db, "f/perf/identity", 910001, value).await; + profile_workload(db, "flow_identity", || { + flow_payload("f/perf/identity", 910001) + }) + .await; +} + +#[sqlx::test(fixtures("base"))] +#[ignore = "profiling harness, see docs/perf-profiling.md"] +async fn perf_flow_bash(db: Pool) { + let step = |id: &str| { + json!({"id": id, "value": {"type": "rawscript", "language": "bash", + "content": "echo 1\n", "input_transforms": {}}}) + }; + let value = json!({"modules": [step("a"), step("b"), step("c")]}); + insert_flow(&db, "f/perf/bashflow", 910002, value).await; + profile_workload(db, "flow_bash", || flow_payload("f/perf/bashflow", 910002)).await; +} diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index e278b644d5..a4a9d41a50 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -171,10 +171,10 @@ impl AuthCache { // from a job's WM_TOKEN, even on an AUTH_CACHE hit whose cached authed // predates this field. opt_job_authed.authed.job_id = opt_job_authed.job_id; - // The workspace's guest switch is enforced here, once, for every guest request - // — not per handler, where each guest-reachable route would have to remember - // it. Uncached, so turning guests off takes effect on the next request of every - // guest session and every token derived from one. + // The no-account rule is enforced here, once, for every guest request — not per + // handler, where each guest-reachable route would have to remember it. Uncached, + // so an account provisioned after the mint ends the guest session, and every + // token derived from one, on its next request. if crate::scopes::has_guest_sentinel(opt_job_authed.authed.scopes.as_deref()) { let Some(w_id) = w_id else { return None }; let email = &opt_job_authed.authed.email; @@ -277,11 +277,9 @@ impl AuthCache { return None; } }; - // The workspace switch, the instance switch and the app being in guest - // mode, in one answer (guest_app_admits). The door re-reads the switches - // and the no-account rule per request through the sentinel below - // (guest_session_stands), so turning any of them off stops a cached JWT - // session on its next call. + // The app being in guest mode (guest_app_admits). The door re-reads the + // no-account rule per request through the sentinel below + // (guest_session_stands), and the on-behalf resolver re-reads the mode. match windmill_common::workspaces::guest_app_admits( &self.db, w_id, @@ -811,8 +809,8 @@ impl AuthCache { } /// How long a guest JWT resolves from the auth cache before the arm re-runs (and -/// re-reads the key). A guest JWT is not revocable except by the workspace switch or -/// by rotating the key, so the entry must be short enough that a rotated key bites +/// re-reads the key). A guest JWT is not revocable except by taking its app out of +/// guest mode or by rotating the key, so the entry must be short enough that a rotated key bites /// soon, unlike a normal token whose row can be deleted. Also what makes the /// day-keyed activity dedupe below reachable across a midnight. const GUEST_JWT_CACHE_TTL: chrono::Duration = chrono::Duration::minutes(5); diff --git a/backend/windmill-api-integration-tests/tests/users.rs b/backend/windmill-api-integration-tests/tests/users.rs index 3a52e94e4a..cf9d2c39d5 100644 --- a/backend/windmill-api-integration-tests/tests/users.rs +++ b/backend/windmill-api-integration-tests/tests/users.rs @@ -1160,3 +1160,55 @@ async fn test_set_password_revokes_mcp_oauth_grants(db: Pool) -> anyho Ok(()) } + +/// Without automatic usernames, a username is only unique within a workspace, so renaming one +/// account must leave the memberships of a like-named account elsewhere alone. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_rename_keeps_other_workspaces_like_named_memberships( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + sqlx::raw_sql( + "INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'test-user'); + INSERT INTO usr (workspace_id, email, username, is_admin, role) + VALUES ('other-workspace', 'test3@windmill.dev', 'test-user-2', false, 'User'); + INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('test-workspace', 'ops', '', '{}'), ('other-workspace', 'finance', '', '{}'); + INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES + ('test-workspace', 'ops', 'test-user-2'), + ('other-workspace', 'finance', 'test-user-2');", + ) + .execute(&db) + .await?; + + let resp = authed(client().post(format!( + "http://localhost:{port}/api/users/rename/test2@windmill.dev" + ))) + .json(&json!({ "new_username": "alice" })) + .send() + .await?; + assert_eq!(resp.status(), 200, "rename: {}", resp.text().await?); + + let memberships: Vec<(String, String, String)> = sqlx::query_as( + "SELECT workspace_id, group_, usr FROM usr_to_group + WHERE group_ IN ('ops', 'finance') ORDER BY workspace_id", + ) + .fetch_all(&db) + .await?; + assert_eq!( + memberships, + vec![ + ( + "other-workspace".into(), + "finance".into(), + "test-user-2".into() + ), + ("test-workspace".into(), "ops".into(), "alice".into()), + ] + ); + + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index b5d9df7f32..70d230d08a 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -163,7 +163,6 @@ pub fn workspaced_service() -> Router { ) .route("/edit_deploy_ui_config", post(edit_deploy_ui_config)) .route("/edit_default_app", post(edit_default_app)) - .route("/edit_guest_access", post(edit_guest_access)) .route( "/edit_add_admins_and_developers_to_forks", post(edit_add_admins_and_developers_to_forks), @@ -350,13 +349,10 @@ pub struct WorkspaceSettings { #[serde(skip_serializing_if = "Option::is_none")] pub public_app_execution_limit_per_minute: Option, pub error_handler_fallback_to_instance_alerts: bool, - /// Whether this workspace admits guest sessions (`ExecutionMode::Guest`). An app's - /// own `execution_mode: guest` is inert while this is off. - pub guest_access_enabled: bool, /// The key a guest JWT is verified against: a PEM public key, or a JWKS URL, at most /// one (a DB CHECK enforces it). Public material, not a secret, so it is admin- /// readable here. `None`/`None` falls back to the instance issuer (`JWT_EXT_JWKS_URL`) - /// off cloud, or accepts no JWT guest if none is set; `guest_access_enabled` is the switch. + /// off cloud, or accepts no JWT guest if none is set. #[serde(skip_serializing_if = "Option::is_none")] pub guest_jwt_public_key: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -384,9 +380,6 @@ pub struct WorkspacePublicSettings { pub teams_team_guid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mute_critical_alerts: Option, - /// Not sensitive, and the app editor needs it to say whether the guest rung is - /// live -- an app can be set to `guest` while the workspace has guests off. - pub guest_access_enabled: bool, /// Read by the fork dialog, which tells the forker who else the fork will include. pub add_admins_and_developers_to_forks: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -1214,7 +1207,6 @@ async fn get_settings( variable_expiration_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, - guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks @@ -1258,7 +1250,6 @@ async fn get_public_settings( teams_team_name, teams_team_guid, mute_critical_alerts, - guest_access_enabled, add_admins_and_developers_to_forks, deploy_ui, large_file_storage, @@ -5947,53 +5938,6 @@ async fn edit_default_app( )); } -#[derive(Deserialize)] -struct EditGuestAccess { - guest_access_enabled: bool, -} - -/// Turn guest sessions on or off for this workspace. Off by default, and off is -/// authoritative and immediate: the switch is re-read where a guest session is -/// minted (`guest_app_admits`) and at the auth door on every guest request, so an app -/// whose policy already says `guest` — pushed by git-sync, say — closes to guests on -/// the next request, sessions already issued included. -async fn edit_guest_access( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(EditGuestAccess { guest_access_enabled }): Json, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - if guest_access_enabled { - windmill_common::workspaces::require_guest_support()?; - } - - let mut tx = db.begin().await?; - sqlx::query!( - "UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2", - guest_access_enabled, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.edit_guest_access", - ActionKind::Update, - &w_id, - Some(&guest_access_enabled.to_string()), - None, - ) - .await?; - tx.commit().await?; - - Ok(format!( - "Guest access set to {guest_access_enabled} for workspace {w_id}" - )) -} - #[derive(Deserialize)] struct EditAddAdminsAndDevelopersToForks { add_admins_and_developers_to_forks: bool, @@ -6039,13 +5983,13 @@ async fn edit_add_admins_and_developers_to_forks( struct EditGuestJwtKey { /// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the /// workspace key; verification then falls back to the instance issuer (`JWT_EXT_JWKS_URL`) - /// off cloud, or refuses the JWT if none is set. The off-switch is `guest_access_enabled`. + /// off cloud, or refuses the JWT if none is set. public_key: Option, jwks_url: Option, } /// Configure the key a guest JWT (`jwt_guest_`) is verified against for this workspace. -/// Workspace-admin gated, like the guest switch: guests are free up to the instance +/// Workspace-admin gated: guests are free up to the instance /// allowance on any plan, so configuring their key needs no licence. The key is /// validated before it is stored so a typo is refused here, not silently on every guest /// later: a PEM must parse as an RS/ES public key (HS* has no PEM form and is @@ -15813,7 +15757,6 @@ pub enum SettingsEdit { Ducklake, DbtWarehouses, DeployUi, - GuestAccess, GuestJwtKey, PublicAppRateLimit, AddAdminsAndDevelopersToForks, @@ -15885,7 +15828,6 @@ pub async fn apply_settings_edit( DeployUi => edit_deploy_ui_config(a, d, p, settings_edit_body(edit, body)?).await?, #[cfg(not(feature = "enterprise"))] DeployUi => edit_deploy_ui_config(a, d, p).await?, - GuestAccess => edit_guest_access(a, d, p, settings_edit_body(edit, body)?).await?, GuestJwtKey => edit_guest_jwt_key(a, d, p, settings_edit_body(edit, body)?).await?, PublicAppRateLimit => { edit_public_app_rate_limit(d, p, a, settings_edit_body(edit, body)?).await? diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index aea887624d..a159935f3c 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -135,7 +135,7 @@ pub(crate) async fn change_workspace_id( // Duplicate workspace settings (keep copy in old workspace for reference) info!("Duplicating workspace_settings table"); sqlx::query!( - "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2", + "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2", &rw.new_id, &old_id ) diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index ad312c43e3..031d1cce51 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -35080,7 +35080,7 @@ ] }, "sleep": { - "description": "Delay before executing this step (in seconds or as expression)", + "description": "Delay after this step completes, before the next step starts (in seconds or as expression)", "$ref": "#/components/schemas/schemas-InputTransform" }, "cache_ttl": { @@ -47034,7 +47034,7 @@ ] }, "sleep": { - "description": "Delay before executing this step (in seconds or as expression)", + "description": "Delay after this step completes, before the next step starts (in seconds or as expression)", "$ref": "#/components/schemas/schemas-InputTransform" }, "cache_ttl": { diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 816a7bce38..2c4302f3f1 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.851.0 + version: 1.853.0 title: Windmill API contact: @@ -4304,15 +4304,11 @@ paths: $ref: "#/components/schemas/WorkspaceDeployUISettings" mute_critical_alerts: type: boolean - guest_access_enabled: - type: boolean - description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false. add_admins_and_developers_to_forks: type: boolean description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role. required: - workspace_id - - guest_access_enabled - add_admins_and_developers_to_forks /w/{workspace}/workspaces/get_settings: @@ -4397,9 +4393,6 @@ paths: error_handler_fallback_to_instance_alerts: type: boolean description: Report failed jobs to the instance critical alert channels when no workspace error handler is set. - guest_access_enabled: - type: boolean - description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false. guest_jwt_public_key: type: string description: PEM public key a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_jwks_url`. @@ -6888,41 +6881,6 @@ paths: schema: type: string - /w/{workspace}/workspaces/edit_guest_access: - post: - summary: enable or disable guest sessions for this workspace - description: >- - Guests are people the identity provider authenticates who have no Windmill - account; the `guest` app execution mode admits them. Off by default. Re-read - where a guest session is minted and at the auth door on every guest request, so - turning it off takes effect immediately, for sessions already issued and for - apps whose policy already says `guest`. Turning it *on* is refused with a 400 - where guests are unavailable (the shared cloud); turning it off always works. - operationId: editGuestAccess - tags: - - workspace - parameters: - - $ref: "#/components/parameters/WorkspaceId" - requestBody: - description: Whether guest sessions are admitted - required: true - content: - application/json: - schema: - type: object - properties: - guest_access_enabled: - type: boolean - required: - - guest_access_enabled - responses: - "200": - description: status - content: - text/plain: - schema: - type: string - /w/{workspace}/workspaces/edit_add_admins_and_developers_to_forks: post: summary: choose whether new forks of this workspace start with its admins and developers @@ -6964,7 +6922,7 @@ paths: verified against this key: a PEM public key (RS/ES family, HS* refused) or a JWKS URL, at most one. Both empty clears the workspace key; off cloud, verification then falls back to the instance issuer (`JWT_EXT_JWKS_URL`) if one is set, else no guest - JWT is accepted (`guest_access_enabled` is the on/off switch). Workspace-admin gated. + JWT is accepted. Workspace-admin gated. The key is validated before it is stored. Setting a key is refused with a 400 where guests are unavailable (the shared cloud); clearing one always works. operationId: editGuestJwtKey @@ -10584,9 +10542,8 @@ paths: summary: whether the app behind a custom path admits guests description: >- The custom-path counterpart of `getGuestEntry`. Unauthenticated; 404 unless - the app's execution mode is `guest` AND its workspace has - `guest_access_enabled` AND the instance has not set `guest_access_disabled`, - and never on a deployment where guests are unavailable (the shared cloud). + the app's execution mode is `guest`, and never on a deployment where guests + are unavailable (the shared cloud). Returns the workspace too, since a custom URL may not carry it. operationId: getGuestEntryByCustomPath tags: @@ -15706,11 +15663,9 @@ paths: summary: whether the app behind a share secret admits guests description: >- Unauthenticated: what a signed-out visitor reads to learn that signing in - would let them in. 404 unless the app's execution mode is `guest` AND the - workspace has `guest_access_enabled` AND the instance has not set the - `guest_access_disabled` global setting, and never on a deployment where guests - are unavailable (the shared cloud), so it says nothing about apps that are not - open to guests. Discloses only the app path, to a caller already + would let them in. 404 unless the app's execution mode is `guest`, and never + on a deployment where guests are unavailable (the shared cloud), so it says + nothing about apps that are not open to guests. Discloses only the app path, to a caller already holding the share secret. operationId: getGuestEntry tags: @@ -32657,16 +32612,12 @@ components: Guests are free up to `free_allowance` distinct emails over the trailing `window_days`. Past that an Enterprise plan meters them (`metered`, four guests to one seat: `billable_guests`, `guest_seats`); every other plan and build - admits no new email until the count drops. `instance_enabled` is the superadmin - switch (`guest_access_disabled` global setting) every workspace switch sits under. - `available` is whether this deployment can have guests at all: false on the shared - cloud, where guest access requires a self-hosted or dedicated deployment, and every - other field and switch is then moot. + admits no new email until the count drops. `available` is whether this + deployment can have guests at all: false on the shared cloud, where guest access + requires a self-hosted or dedicated deployment, and every other field is then moot. properties: available: type: boolean - instance_enabled: - type: boolean guest_count: type: integer format: int64 @@ -32685,7 +32636,6 @@ components: format: int64 required: - available - - instance_enabled - guest_count - window_days - free_allowance @@ -37762,9 +37712,8 @@ components: requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the - identity provider authenticates, is ever assumed. A guest is only - admitted where the workspace also has `guest_access_enabled`, which is - checked when the session is minted and again on every guest request + identity provider authenticates, is ever assumed. Widening an app to + `guest` is gated by the `RestrictGuestAppDeployment` protection rule on_behalf_of: type: string description: The user or group the app runs as in anonymous or publisher mode (e.g. 'u/admin' or 'g/mygroup'). The authority for the app's identity. diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 1b684cdefc..c7b61a1211 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -306,9 +306,8 @@ pub enum ExecutionMode { /// publisher exactly as in [`ExecutionMode::Publisher`]. Such a viewer holds a /// guest session: an identity with no account at all (no `password` row, no `usr` /// row anywhere), which is what keeps it off every row-based seat counter; what a - /// guest costs instead is the allowance in `windmill_common::workspaces`. Honored - /// only where `workspace_settings.guest_access_enabled` is on, re-read at the auth - /// door on every guest request. + /// guest costs instead is the allowance in `windmill_common::workspaces`. Widening + /// an app into it is gated by `RestrictGuestAppDeployment`. Guest, /// Default for a policy that omits `execution_mode`. It MUST stay a mode /// that requires an authenticated viewer: an omitted field must never be @@ -386,8 +385,8 @@ fn refuse_guest_mode_where_unavailable( /// A guest is authorized by its token's scope and never by an ACL probe: it holds no /// `usr` row, so RLS finds nothing for it and every guest would read as having no /// access. That scope is also what keeps a guest session to the one app it was minted -/// for, even though the mode itself admits anyone signed in. The workspace's guest -/// switch is not checked here: `AuthCache` enforces it for every guest request. +/// for, even though the mode itself admits anyone signed in. The no-account rule is not +/// checked here: `AuthCache` enforces it for every guest request. pub fn authorize_non_member_viewer( mode: ExecutionMode, app_path: &str, diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index 9add0792ee..079ee5e519 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -1332,7 +1332,7 @@ is, a different one moves it there and archives the old path"), }, "execution_mode": { "type": "string", - "description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. A guest is only admitted where the workspace also has `guest_access_enabled`, which is checked when the session is minted and again on every guest request. Possible values: viewer, publisher, guest, anonymous" + "description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. Widening an app to `guest` is gated by the `RestrictGuestAppDeployment` protection rule. Possible values: viewer, publisher, guest, anonymous" }, "on_behalf_of": { "type": "string", @@ -1449,7 +1449,7 @@ is, a different one moves it there and archives the old path"), }, "execution_mode": { "type": "string", - "description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. A guest is only admitted where the workspace also has `guest_access_enabled`, which is checked when the session is minted and again on every guest request. Possible values: viewer, publisher, guest, anonymous" + "description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. Widening an app to `guest` is gated by the `RestrictGuestAppDeployment` protection rule. Possible values: viewer, publisher, guest, anonymous" }, "on_behalf_of": { "type": "string", diff --git a/backend/windmill-api/src/mcp/session/deploy.rs b/backend/windmill-api/src/mcp/session/deploy.rs index 5512c92913..1dc8b1453e 100644 --- a/backend/windmill-api/src/mcp/session/deploy.rs +++ b/backend/windmill-api/src/mcp/session/deploy.rs @@ -317,15 +317,10 @@ async fn undeployed_runnable_targets(ctx: &Ctx<'_>, runnables: &Map) -> bool { - let (Ok(usage), Ok(settings)) = ( - ctx.get(&ctx.ws("workspaces/guest_usage")).await, - ctx.get(&ctx.ws("workspaces/get_public_settings")).await, - ) else { + let Ok(usage) = ctx.get(&ctx.ws("workspaces/guest_usage")).await else { return false; }; truthy(usage.get("available")) - && truthy(usage.get("instance_enabled")) - && truthy(settings.get("guest_access_enabled")) } /// Deploys a raw app draft from its sources: the server bundles them on a worker and diff --git a/backend/windmill-api/src/mcp/session/gen/instructions.json b/backend/windmill-api/src/mcp/session/gen/instructions.json index 33ba824a4d..f197f795ce 100644 --- a/backend/windmill-api/src/mcp/session/gen/instructions.json +++ b/backend/windmill-api/src/mcp/session/gen/instructions.json @@ -252,7 +252,7 @@ "script.duckdb": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (duckdb)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` 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 it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type 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 declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`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 arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_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 schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor 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.\n\nA 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).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# DuckDB\n\nArguments are defined with comments and used with `$name` syntax:\n\n```sql\n-- $name (text) = default\n-- $age (integer)\nSELECT * FROM users WHERE name = $name AND age > $age;\n```\n\n## Ducklake Integration\n\nAttach Ducklake for data lake operations:\n\n```sql\n-- Main ducklake\nATTACH 'ducklake' AS dl;\n\n-- Named ducklake\nATTACH 'ducklake://my_lake' AS dl;\n\n-- Then query\nSELECT * FROM dl.schema.table;\n```\n\n## External Database Connections\n\nConnect to external databases using resources:\n\n```sql\nATTACH '$res:path/to/resource' AS db (TYPE postgres);\nSELECT * FROM db.schema.table;\n```\n\n## S3 File Operations\n\nRead files from S3 storage:\n\n```sql\n-- Default storage\nSELECT * FROM read_csv('s3:///path/to/file.csv');\n\n-- Named storage\nSELECT * FROM read_csv('s3://storage_name/path/to/file.csv');\n\n-- Parquet files\nSELECT * FROM read_parquet('s3:///path/to/file.parquet');\n\n-- JSON files\nSELECT * FROM read_json('s3:///path/to/file.json');\n```\n\n### Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for it\nand binds the arg as the bare `s3://storage/key` URI, which DuckDB's reader\nfunctions consume directly:\n\n```sql\n-- $file (s3object)\nSELECT * FROM read_parquet($file);\n```\n\nWorks with any DuckDB reader: `read_csv($file)`, `read_json($file)`, etc.\n\n### Writing query results to S3\n\nDuckDB writes to S3 natively via `COPY ... TO`:\n\n```sql\nCOPY (SELECT * FROM users) TO 's3:///exports/users.parquet' (FORMAT PARQUET);\n```\n\nUse this instead of the `-- s3` streaming directive supported by the other SQL\ndialects — that directive is not available in DuckDB.\n", "script.bunnative": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (bunnative)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` 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 it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type 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 declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`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 arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_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 schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor 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.\n\nA 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).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# TypeScript (Bun Native)\n\nNative TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes `fetch` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with `//native` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. `./helper.ts`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on `fetch` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, `node:*` modules, child processes, native addons) will not work on the native worker; use the regular `bun` language for those.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\n//native\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\n//native\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\n## Imports\n\n**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides `fetch` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (`node:fs`, `child_process`, the `Bun` API, native modules) belongs in a regular `bun` script instead. Use the globally available `fetch` for HTTP:\n\n```typescript\n//native\nexport async function main(url: string) {\n const response = await fetch(url);\n return await response.json();\n}\n```\n\n## Pure computation: `//no_network`\n\nA native script that only transforms its inputs can declare `//no_network` in its leading comment block (right after `//native`). The runtime then refuses every connection the script attempts: `fetch` to any host, the Windmill API (so `windmill-client` calls fail too), raw sockets and unix sockets, and `WM_TOKEN` is not set. Without `//native` it means something else: the script must run in an nsjail sandbox (`//sandbox`, or instance-wide sandboxing), which then loses its network, and it fails to run otherwise. Use it only when the script needs no external data beyond its arguments:\n\n```typescript\n//native\n//no_network\nexport async function main(items: { price: number; qty: number }[]) {\n return items.reduce((sum, i) => sum + i.price * i.qty, 0);\n}\n```\n\n## Windmill Client\n\n`windmill-client` works on the native worker (its calls go over `fetch`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). It handles auth, the workspace, and the base URL for you. Reserve raw `fetch` for calling *external* HTTP APIs that aren't Windmill.\n\nThe full `windmill-client` API reference (every exported function and its signature) is included below — consult it for the exact method instead of hand-rolling a `fetch` against the Windmill API.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\n//native\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"amqp\"\n | \"gcp\"\n | \"azure\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form — always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\n//native\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\n//native\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true, tag: string | null = null): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step(name: string, fn: () => T | Promise,): Promise>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit,): SqlStatement\n", "script.dbt": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (dbt)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` 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 it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type 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 declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`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 arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_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 schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor 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.\n\nA 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).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n", - "flow": "# Global draft flow instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Never invent a folder.\n- `write_flow` mirrors flow mode's `set_flow_json`: pass `path`, optional `summary`, optional `description`, required `modules`, and optional `schema`, `preprocessor_module`, `failure_module`, `groups`, and `notes`. `summary` and `description` are top-level flow metadata (not part of the compact value `patch_flow_json` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. When overwriting an existing flow, top-level flow settings (see below) are preserved from the current flow — use `patch_flow_json` to change them.\n- `read_workspace_item` returns a compact flow `value` object with `modules`, `schema`, `preprocessor_module`, `failure_module`, `groups`, `notes`, and any top-level flow settings that are set.\n- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with `patch_flow_json`: same_worker, preserve_step_tags, concurrent_limit, concurrency_key, concurrency_time_window_s, debounce_delay_s, debounce_key, debounce_args_to_accumulate, max_total_debouncing_time, max_total_debounces_amount, skip_expr, cache_ttl, cache_ignore_s3_path, delete_after_secs, flow_env, priority, early_return, chat_input_enabled. For example, `chat_input_enabled: true` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow.\n- `modules` contains normal sequential modules. Use top-level `preprocessor_module` and `failure_module` for special modules; do not put `preprocessor` or `failure` in `modules`.\n- Every module needs a stable unique `id` and a useful `summary` when the schema supports it.\n- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.\n- When writing rawscript module code, call `get_instructions` with `subject: \"script\"` and the rawscript language first.\n\n## Groups and notes\n\n- Add `groups` to any non-trivial flow without being asked; \"Organizing Flows: Groups and Notes\" in the reference below gives the fields and the rules the editor enforces. Pass `null` to `write_flow` to clear them, and the same for `notes`.\n- With `patch_flow_json`, edit `groups` and `notes` the same way as any other field — they appear as top-level keys in the compact flow value.\n\n## Compact view: how rawscript bodies surface in tool I/O\n\n- `read_workspace_item` and `patch_flow_json` operate on a **compact view** of the flow: every rawscript module's `value.content` is replaced with the placeholder `\"inline_script.\"` so inline script bodies don't bloat tool I/O. Schema, groups, notes, preprocessor_module and failure_module are all shown in this view.\n- Inline rawscript content is **not** part of the JSON `patch_flow_json` sees. Edits to inline bodies happen via dedicated tools:\n - `read_flow_module_code(path, module_id)` — returns the raw inline script content for one module.\n - `set_flow_module_code(path, module_id, code)` — overwrites that module's inline script content; saves to the draft.\n- Use `patch_flow_json` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups/notes. Use `set_flow_module_code` for changes inside a specific rawscript body.\n- When **adding a new rawscript module** with `patch_flow_json`, set its `content` to the placeholder `\"inline_script.\"` (matching the compact view) — never real code — then **immediately fill the body** with `set_flow_module_code(path, module_id, code)`. The module is saved with an empty body until you do; the tool result lists the module ids still awaiting code. A placeholder that names anything other than the module's own id or an existing rawscript module is rejected.\n- `write_flow` is for full overwrites / create-from-scratch. Its `modules`, `preprocessor_module`, and `failure_module` arguments are **non-compact** flow modules: inline each rawscript body directly in `content` by default. But if a body is long or quote/backslash-heavy enough that escaping it into the JSON string is error-prone — or if a `write_flow` call comes back with a JSON parse error — create that module with **empty content** (`\"content\": \"\"`) and fill it with `set_flow_module_code(path, module_id, code)`, whose `code` is a plain argument with no nested escaping. `write_flow` reports which modules still have empty bodies so you know what to fill.\n - When overwriting an **existing** flow, set `\"content\": \"inline_script.\"` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected.\n\n\n# Windmill flow authoring reference\n\n# Windmill Flow Building Guide\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Hard Structural Rules\n\nThese are strict Windmill schema rules. Follow them exactly.\n\n- `value.modules` is only for normal sequential steps\n- `value.preprocessor_module` and `value.failure_module` are special top-level fields inside `value`, not entries in `value.modules`\n- If a flow needs a preprocessor, create `value.preprocessor_module` with `id: preprocessor`\n- If a flow needs a failure handler, create `value.failure_module` with `id: failure`\n- Do NOT create regular modules inside `value.modules` named `preprocessor` or `failure`\n- `preprocessor_module` and `failure_module` only support `script` or `rawscript`\n- `preprocessor_module` runs before normal modules and cannot reference `results.*`\n- `failure_module` can use the `error` object with `error.message`, `error.step_id`, `error.name`, and `error.stack`\n- A flow whose `failure_module` runs still ends as failed, unless the handler returns an object with `recover: true`: the flow then ends as a success. This also holds when the failing step is inside a loop, a branch or a subflow. `recover` never changes which steps run; whether a loop or branch carries on past a failure is still decided by its own settings (`skip_failures`, `continue_on_error`)\n\nCorrect shape:\n\n```yaml\nvalue:\n preprocessor_module:\n id: preprocessor\n value:\n type: rawscript\n ...\n failure_module:\n id: failure\n value:\n type: rawscript\n ...\n modules:\n - id: process_event\n value:\n type: rawscript\n ...\n```\n\nIncorrect shape:\n\n```yaml\nvalue:\n modules:\n - id: preprocessor\n ...\n - id: process_event\n ...\n - id: failure\n ...\n```\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## AI Agent Modules\n\nAn `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped\nobject with an extra `value.tool_type`: `flowmodule` for a script or inline script tool (or a nested\nagent or AI decision), `mcp` for an MCP server tool, `websearch` for web search, `question` for a\nbuilt-in `ask_question` tool (the agent stops, without holding a worker, until someone answers from\nthe run page or the chat; optional `value.timeout` in seconds, default 1800). Set\n`requires_approval: true` on a `flowmodule` or `mcp` tool to have a person approve or reject each of\nits calls before it runs.\n\n```json\n{\n \"id\": \"support_agent\",\n \"summary\": \"AI agent for customer support\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"openai\", \"resource\": \"$res:f/ai_providers/openai\", \"model\": \"gpt-4o\" }\n },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.query\" },\n \"system_prompt\": { \"type\": \"static\", \"value\": \"You are a helpful assistant.\" }\n },\n \"tools\": [\n {\n \"id\": \"search_docs\",\n \"summary\": \"search_documentation\",\n \"description\": \"Search the product documentation. Use it whenever the user asks how a feature works.\",\n \"value\": {\n \"tool_type\": \"flowmodule\",\n \"type\": \"rawscript\",\n \"language\": \"bun\",\n \"content\": \"export async function main(query: string) { return ['doc1', 'doc2']; }\",\n \"input_transforms\": { \"query\": { \"type\": \"ai\" } }\n }\n }\n ]\n }\n}\n```\n\n- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script\n that runs the flow\n- Each argument of a tool is either the model's or fixed. `{ \"type\": \"ai\" }` (or no value) lets the\n model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix\n what the model must not choose, such as a resource, a table name or a limit\n- `provider` is an object, not a bare resource string: `{ \"kind\": ,\n \"resource\": \"$res:\", \"model\": }`. Required unless the module links to a saved\n agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its\n fields to flow inputs instead — see below\n\n### Chat-Mode Flows\n\nA flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer\nsends one message per turn and renders the conversation. It needs a required `user_message` string\ninput, read by the agent. Any other flow input the composer does not edit itself is asked for\nunder Configure inputs.\n\n**A static `provider` gives a chat that cannot change its model.** Feed it from flow inputs\ninstead, either way round: one input carrying the whole object (`\"expr\": \"flow_input.model_config\"`)\nmakes every field editable, or wire it field by field to fix some and expose others. A field the\nchat can write becomes a control in the composer — a provider picker, a model list, a thinking\ncontrol — and a field left static is fixed, with no control drawn for it. `kind` is the one\nexception: the composer writes it only together with `resource`, since a provider is picked as a\npair, so a `kind` input wired on its own stays askable under Configure inputs and nothing the run\nneeds becomes unreachable.\n\n```json\n{\n \"id\": \"chat_agent\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"javascript\",\n \"expr\": \"({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })\"\n },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.user_message\" },\n \"user_attachments\": { \"type\": \"javascript\", \"expr\": \"flow_input.files\" },\n \"memory\": { \"type\": \"static\", \"value\": { \"kind\": \"compaction\" } },\n \"streaming\": { \"type\": \"static\", \"value\": true },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" }\n },\n \"tools\": []\n }\n}\n```\n\n- Wiring field by field means one object literal whose values are literals or bare `flow_input.x`\n references. A spread, a call or a computed key leaves the composer unable to tell which input\n feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object\n is read instead as that one input carrying every field\n- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same\n two controls through its `model` and `reasoning_effort` inputs, which replace what the agent\n saved: `\"model\": { \"type\": \"javascript\", \"expr\": \"flow_input.model\" }`. An empty model keeps the\n saved one, an empty effort sends none, and the provider resource always stays the agent's\n- `memory` is what lets the agent see earlier turns; without it every message starts from nothing\n- `streaming` on makes the answer and its thinking appear token by token instead of all at once\n- `user_attachments` points at a flow input typed as an array of s3 objects\n (`{ \"type\": \"array\", \"items\": { \"type\": \"object\", \"resourceType\": \"s3object\" } }`), so files\n sent with a message reach the agent\n- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the\n conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat\n supplies it itself; a run driven any other way has to pass it or the server refuses the job\n\n### Tool Naming Rules\n\nThese rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` or `question`\ntool's `summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP\nserver's own tool names, so none is name-checked at all — leave those summaries as they are.\n\n- A flowmodule tool's `summary` is the **name the agent calls it by**, not a human label. Put the\n human-readable explanation in `description`\n- `summary` must match `^[a-zA-Z0-9_]+$`: letters, numbers and underscores only. No spaces, dashes,\n dots or accents — `search_documentation`, never `Search documentation`\n- Always set `summary`. It must be unique among that agent's tools, and must not be one of the\n reserved ids (`do`, `bg`, `ctx`, `state`, `if`, `else`, `for`, `delete`, `while`, `new`, `in`,\n `failure`, `preprocessor`, `as`, `Input`, `Result`, `Trigger`)\n- A tool name outside that character set fails any run that offers the tool to the agent, with `Invalid tool name`.\n The flow write tools refuse such a name.\n- Tool `id` follows the same rules as any module ID — unique across the flow, underscores not spaces\n- `description` is optional free text telling the agent when and how to call the tool. Set it\n whenever the name alone does not make that obvious; it overrides the description derived from the\n underlying script\n\n## AI Decision Modules\n\nAn `aidecision` module asks a decision model (TypeSafe's Jev, Cloudflare's Clef or OpenAI's Decisions API) typed questions about a `state` and\nanswers each with calibrated probabilities instead of text. Prefer it over an `aiagent` when the step\nis a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it\nis faster, cheaper, and its answers have a fixed shape.\n\n```json\n{\n \"id\": \"triage\",\n \"summary\": \"Classify the ticket\",\n \"value\": {\n \"type\": \"aidecision\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"typesafe\", \"resource\": \"$res:f/ai/typesafe\", \"model\": \"jev-latest\" }\n },\n \"state\": { \"type\": \"javascript\", \"expr\": \"flow_input.message\" },\n \"questions\": {\n \"type\": \"static\",\n \"value\": {\n \"intent\": {\n \"type\": \"choice\",\n \"instructions\": \"What does the customer want?\",\n \"criteria\": { \"refund\": \"Money back\", \"bug\": \"Something is broken\", \"other\": \"Anything else\" }\n },\n \"urgency\": {\n \"type\": \"score\",\n \"instructions\": \"How urgent is the ticket?\",\n \"criteria\": [\"Can wait\", \"This week\", \"Today\", \"Right now\"]\n },\n \"angry\": { \"type\": \"noul\", \"instructions\": \"Is the customer angry?\" }\n }\n }\n }\n }\n}\n```\n\n- `provider.kind` is `typesafe` (`model` `jev-latest` unless a version is pinned), `cloudflare`\n (`model` `clef`, or `clef-flash` for faster answers) or `openai` (`model` `gpt-6-luna`, never a\n chat model); the resource is of that same type\n- `state` is the content to evaluate: usually a text, such as the message to classify. To combine\n several values, pass an object with descriptive keys holding only what the questions need\n (`({ message: flow_input.message, plan: results.get_account.plan })`); an array of strings also works\n- `questions` maps each question name to `{ type, instructions, criteria }`:\n - `choice`: `criteria` maps each option to its description (up to 255 options). The answer has\n `choice`, `probabilities` and `confidence`\n - `score`: `criteria` is an ordered array of 2 to 10 level descriptions. The answer has `score`,\n `legend`, `probabilities` and `confidence`\n - `noul`: yes/no, `criteria` optionally `{ \"true\": ..., \"false\": ... }` descriptions. The answer has\n `noul`, the probability of yes from 0 to 1\n- The result is `{ output, model, usage }` with `output` keyed by question name, so a later step\n reads `results.triage.output.intent.choice`, `results.triage.output.urgency.score` or\n `results.triage.output.angry.noul > 0.7`\n- On `openai` the same `questions` are translated for its Decisions API and the answers read the\n same way (`choice`, `score`, `noul`), except that `probabilities` is a list of\n `{ value, probability }` (plus `label` on a score) and a score has no `legend`. A question OpenAI\n refuses to answer fails the step. A `state` that is\n an array of user messages (`[{ role: \"user\", content: [{ type: \"input_text\", text }, { type: \"input_image\", image_url: \"data:image/png;base64,...\" }] }]`)\n is sent as is, which is how an image is evaluated; any other object or array is sent as JSON text\n\n### Branching on the Answers\n\nTo route on the answers, put a `branchone` right after the decision, one branch per option, with\neach `expr` reading the decision by id:\n\n```json\n{\n \"id\": \"route\",\n \"value\": {\n \"type\": \"branchone\",\n \"branches\": [\n { \"summary\": \"refund\", \"expr\": \"results.triage.output.intent.choice === 'refund'\", \"modules\": [...] },\n { \"summary\": \"bug\", \"expr\": \"results.triage.output.intent.choice === 'bug'\", \"modules\": [...] }\n ],\n \"default\": [...]\n }\n}\n```\n\nFor a yes/no question, one branch with `\"expr\": \"results.triage.output.angry.noul >= 0.5\"` (tune the\nthreshold as needed) and the `default` as the \"no\" path.\n\n### As an Agent Tool\n\nAn `aidecision` can be a `flowmodule` tool of an `aiagent` (`\"tool_type\": \"flowmodule\", \"type\":\n\"aidecision\"`): set `state` to `{ \"type\": \"ai\" }` so the calling model supplies it, and only `output`\ngoes back to the model. The Tool Naming Rules apply to its `summary`. Only a flow's own agent step\ncan call one, not an agent used as a tool.\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n- AI agent flowmodule tool names with spaces - `summary` is the tool name and only accepts letters, numbers and underscores\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step only when that step result is in scope\n- `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope\n- `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`)\n- `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`)\n- After a suspend step (approval), the next step's javascript expressions also get:\n - `resume` - the last resume payload (the resume form values); with `continue_on_disapprove_timeout`, a disapproval or timeout yields `{ error: { name: \"SuspendedDisapproved\" | \"SuspendedTimedOut\", message } }` instead\n - `resumes` - every resume payload, in arrival order\n - `approvers` - a plain array of strings, one per resume: for a resume through the `resume`/`cancel` URL from `getResumeUrls(approver)`, the `approver` passed there if any; otherwise (including the `approvalPage` link) the resuming user's username, else `\"anonymous\"`, e.g. `approvers[0]`. It is NOT the `{ resume_id, approver }` objects stored in the flow status, so `approvers[0].approver` is always undefined\n\n## Loop Structure Rules\n\n- A `forloopflow` runs its `modules` once per element of `iterator`, a javascript expression returning an array (e.g. `results.get_items`); `parallel: true` runs the iterations concurrently, and `skip_failures: true` carries on past a failed iteration\n- For `whileloopflow`, break the loop with a module-level `stop_after_if`: on the loop module itself, or on an inner step (required when that step carries state via its own `results` — see below)\n- `stop_after_if` is always a sibling of `id` and `value` on a flow module — never a direct key of the loop's `value` object\n- `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition\n- `stop_after_if` is evaluated after each iteration: on the loop module, `result` is that iteration's result (what its last step returned); on an inner step, it is that step's result\n- `flow_input.iter.value` in a `whileloopflow` is just the iteration index (same number as `flow_input.iter.index`) — it never carries state, so `flow_input.iter.value.` is always undefined and a loop whose stop condition depends on it never terminates\n- To carry state across iterations, a step reads its own previous-iteration result via `results.` with a first-iteration fallback (e.g. `results.b ?? flow_input.start`) — but then the loop's `stop_after_if` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where `results.` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own `stop_after_if`, retry or similar, resolve `results` across iterations regardless of stop placement)\n- For state that is just a counter, derive it from the index instead (e.g. `flow_input.iter.index + 1`) — that works in every configuration, including with `stop_after_if` on the loop module\n- If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array\n\nCorrect `whileloopflow` shape:\n\n```yaml\n- id: loop_until_done\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n value:\n type: rawscript\n input_transforms:\n count:\n type: javascript\n expr: flow_input.iter.index + 1\n- id: return_final_state\n value:\n type: rawscript\n input_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done[results.loop_until_done.length - 1]\n```\n\nCorrect `whileloopflow` shape carrying state via `results` (stop condition on the inner step):\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: rawscript\n input_transforms:\n state:\n type: javascript\n expr: results.advance_state ?? flow_input.initial_state\n```\n\nIncorrect `whileloopflow` patterns:\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n stop_after_if:\n expr: result.done === true\n```\n\n```yaml\ninput_transforms:\n state:\n type: javascript\n # iter.value is a number (the iteration index); there is no previous-iteration state\n expr: flow_input.iter.value.count\n```\n\n```yaml\ninput_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done\n```\n\n## Approval / Suspend Structure\n\nAn approval step is a normal **script** step (`type: rawscript` or `type: script`) that is turned into an approval by adding a module-level `suspend`. Its script calls `wmill.getResumeUrls(approver)` to generate the secret resume/cancel URLs and returns them so they can be sent to the approver(s) (Slack, email, etc.) or approved from the run page.\n\n- `suspend` belongs on the flow module object itself, as a sibling of `id` and `value`\n- Never put `suspend` inside `value`\n- Do NOT use `type: identity` for an approval step. An identity step suspends but never produces the resume URLs, so approvers have no link to act on — it is not a functional approval.\n\nCorrect shape:\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n resume_form:\n schema:\n type: object\n properties:\n comment:\n type: string\n required: [comment]\n value:\n type: rawscript\n language: bun\n input_transforms:\n approver:\n type: static\n value: ''\n content: |\n import * as wmill from \"windmill-client\"\n\n export async function main(approver?: string) {\n const urls = await wmill.getResumeUrls(approver)\n // send urls.resume / urls.cancel to the approver(s), e.g. via Slack or email\n return urls\n }\n```\n\nIncorrect shape (suspend misplaced inside `value`):\n\n```yaml\n- id: request_approval\n value:\n type: rawscript\n suspend:\n required_events: 1\n```\n\nIncorrect shape (identity has no resume URLs — not a real approval):\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n value:\n type: identity\n```\n\n## Branch Result Scope Rules\n\n- A `branchone` runs the first of its `branches` whose `expr` is true, in order, and its `default` modules when none is; a `branchall` runs every branch (concurrently with `parallel: true`)\n- Inside a branch, you may reference earlier outer steps and earlier steps in the same branch\n- Outside a `branchone`, do NOT reference ids of steps that only exist inside its branches or default branch. Use `results.` instead\n- Outside a `branchall`, do NOT reference ids of steps inside its branches. Use `results.` instead\n- If downstream steps need a stable shape after a branch, make each branch return the same fields\n- When needed, add a normalization step immediately after the branch and consume `results.` there\n\nCorrect after `branchone`:\n\n```yaml\n- id: route_order\n value:\n type: branchone\n ...\n- id: send_confirmation\n value:\n input_transforms:\n routed:\n type: javascript\n expr: results.route_order\n```\n\nIncorrect after `branchone`:\n\n```yaml\nexpr: results.create_shipment\nexpr: results.create_backorder\n```\n\nCorrect after `branchall`:\n\n```yaml\n- id: enrich_parallel\n value:\n type: branchall\n parallel: true\n ...\n- id: combine_data\n value:\n input_transforms:\n enrichments:\n type: javascript\n expr: results.enrich_parallel\n```\n\n## Input Transforms\n\nEvery rawscript module needs `input_transforms` to map function parameters to values:\n\nStatic transform (fixed value):\n{\"param_name\": {\"type\": \"static\", \"value\": \"fixed_string\"}}\n\nJavaScript transform (dynamic expression):\n{\"param_name\": {\"type\": \"javascript\", \"expr\": \"results.previous_step.data\"}}\n\n## Resource References\n\n- For flow inputs: Use type `\"object\"` with format `\"resource-{type}\"` (e.g., `\"resource-postgresql\"`)\n- For step inputs: Use static value `\"$res:path/to/resource\"`\n\n## Reusing Existing Scripts and Flows\n\nUnless the user asked for new code, look for a workspace script or flow that already does a step's job before writing it, and reuse it by path instead of copying its logic into a rawscript:\n\n- a workspace script: `type: script` with `path` (e.g. `f/folder/send_email`)\n- a workspace flow, run as a subflow: `type: flow` with `path`\n- a Hub script: `type: script` with a `hub///` path\n\nThe step's `input_transforms` must cover the reused item's inputs, so read its input schema first.\n\n## Additional Prompt for AI\n\nA flow's input schema may carry a top-level `prompt_for_ai` string, as a script's can: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that flow, and keep it when you rewrite the schema.\n\n## Organizing Flows: Groups and Notes\n\nGroups and notes shape how a flow reads in the editor; neither changes what it does.\n\n**Segment every non-trivial flow into groups without waiting to be asked.** Whenever a flow has more than a couple of steps, or consecutive steps form a stage (\"fetch\", \"transform\", \"notify\"), put them in a group, and aim for every meaningful step to belong to one. Use notes sparingly, for flow-wide information that belongs to no span of steps: the flow's purpose, key assumptions, warnings, TODOs. One note is usually enough; never label a run of steps with a note, which is what a group is for.\n\n`value.groups` lists the groups, each spanning the steps from `start_id` to `end_id`:\n\n- `start_id`, `end_id` (required): ids of the group's first and last step; the same id for both makes a one-step group\n- `summary`: the group's title\n- `note`: markdown shown under the title\n- `color`: one of `yellow`, `blue`, `green`, `purple`, `pink`, `orange`, `red`, `cyan`, `lime`, `gray`, never a hex code or CSS color; leave it out and the editor picks one\n- `autocollapse`: `true` shows the group collapsed by default\n\nThe editor refuses to draw a flow whose groups break any of these rules:\n\n- `start_id` and `end_id` are steps of the same list: both top-level, or both in the same loop body or branch. A group can hold a loop or branch step whole, but cannot start outside one and end inside it\n- `start_id` does not come after `end_id` in that list\n- groups nest (one entirely inside another) but never partly overlap, and no two groups share both `start_id` and `end_id`\n- groups hold ordinary steps only: never `preprocessor`, `failure`, `Input`, `Result`, `Trigger`, or an AI agent's tools\n\n`value.notes` lists sticky notes, each with a unique `id`, markdown `text`, a `color` from the same list, and `type: free`. The `group` note type is deprecated; use `value.groups` instead.\nLeave a note's `position` and `size` out and they are filled in for you.\n\n## Final Structural Self-Check\n\nBefore finalizing a flow, verify:\n\n- any preprocessor is in `value.preprocessor_module`\n- any failure handler is in `value.failure_module`\n- any approval step has module-level `suspend`\n- no downstream step references inner branch step ids from outside the branch\n- every AI agent flowmodule tool has a unique `summary` made only of letters, numbers and underscores\n- every group starts and ends on steps of the same list, start before end, nesting without partial overlap\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\nTo accept an S3 object as flow input:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"object\",\n \"format\": \"resource-s3_object\",\n \"description\": \"File to process\"\n }\n }\n}\n```\n\n## Using Resources in Flows\n\nOn Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.\n\n### As Flow Input\n\nIn the flow schema, set the property type to `\"object\"` with format `\"resource-{type}\"`:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"database\": {\n \"type\": \"object\",\n \"format\": \"resource-postgresql\",\n \"description\": \"Database connection\"\n }\n }\n}\n```\n\n### When No Resource Type Fits\n\nA connection still comes in as one resource input, not as separate string inputs (a URL, a token) or a variable read inside a step: type the input `resource-c_` with a custom resource type holding the service's connection fields, its secret in a variable the resource references. The `c_` prefix of custom types keeps one apart from a hub type synced later under the same name.\n\n- 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.\n- Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it.\n- A plain variable is fine when the user asks for it.\n\n### As Step Input (Static Reference)\n\nReference a specific resource using `$res:` prefix:\n\n```json\n{\n \"database\": {\n \"type\": \"static\",\n \"value\": \"$res:f/folder/my_database\"\n }\n}\n```\n\n\n## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names.\"},\"on_behalf_of\":{\"type\":\"string\",\"description\":\"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"preserve_step_tags\":{\"type\":\"boolean\",\"description\":\"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag.\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the flow job's args, result and logs after this many seconds following job completion\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead.\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff: the n-th retry waits multiplier \\u00d7 seconds^n\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled.\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead.\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one.\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"},\"error_include_result\":{\"type\":\"boolean\",\"description\":\"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the step's args, result and logs after this many seconds following job completion\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"},\"skin\":{\"type\":\"string\",\"enum\":[\"detailed\",\"minimal\"],\"description\":\"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"job_token_scopes\":{\"type\":\"array\",\"nullable\":true,\"items\":{\"type\":\"string\"},\"description\":\"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"azure_foundry\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\",\"typesafe\",\"cloudflare\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"},\"reasoning_effort\":{\"type\":\"string\",\"description\":\"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryWindow\":{\"type\":\"object\",\"description\":\"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n`memory_id`). Without a memory id the agent runs without memory.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"window\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Number of most recent messages to load and store. 0 turns memory off.\"}},\"required\":[\"kind\",\"context_length\"]},\"MemoryAuto\":{\"type\":\"object\",\"deprecated\":true,\"description\":\"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryCompaction\":{\"type\":\"object\",\"description\":\"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"compaction\"]},\"context_window\":{\"type\":\"integer\",\"description\":\"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"deprecated\":true,\"description\":\"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryWindow\"},{\"$ref\":\"#/components/schemas/MemoryCompaction\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"window\":\"#/components/schemas/MemoryWindow\",\"compaction\":\"#/components/schemas/MemoryCompaction\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"},{\"$ref\":\"#/components/schemas/AiDecision\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\",\"aidecision\":\"#/components/schemas/AiDecision\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"bunnative\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"rlang\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\",\"dbt\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'.\"},\"description\":{\"type\":\"string\",\"description\":\"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.\"},\"job_token_scopes\":{\"type\":\"array\",\"nullable\":true,\"items\":{\"type\":\"string\"},\"description\":\"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it.\"},\"requires_approval\":{\"type\":\"boolean\",\"description\":\"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run.\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"},{\"$ref\":\"#/components/schemas/QuestionToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\",\"question\":\"#/components/schemas/QuestionToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"QuestionToolValue\":{\"type\":\"object\",\"description\":\"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"question\"]},\"timeout\":{\"type\":\"integer\",\"description\":\"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800.\"}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\\nthe prompt; image output always needs it.\\n\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"memory_id\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the `memory_id` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\\nolder `auto` or `manual` memory reads neither history input.\\n\"},\"previous_messages\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while `memory` is off or absent: managed memory\\nignores it, and an older `auto` or `manual` memory reads neither history input.\\n\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"enabled_tools\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of strings naming which of the tools configured in `tools` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"},\"max_iterations\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n\"},\"model\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Only on a step linked to a saved agent (see `agent`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n`flow_input.model` to choose the model per run.\\n\"},\"reasoning_effort\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside `provider`.\\n\"}}},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)\"},\"omit_output_from_conversation\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.\"},\"agent\":{\"type\":\"string\",\"description\":\"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\\n\"},\"tool_inputs\":{\"type\":\"object\",\"description\":\"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n\",\"additionalProperties\":{\"type\":\"object\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}}},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"type\",\"input_transforms\"]},\"AiDecision\":{\"type\":\"object\",\"description\":\"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { : answer },\\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults..output..choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the decision mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"state\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n\"},\"questions\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n\"}},\"required\":[\"provider\",\"state\",\"questions\"]},\"type\":{\"type\":\"string\",\"enum\":[\"aidecision\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)\"}},\"required\":[\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"approval\"]},\"call_id\":{\"type\":\"string\"},\"calls\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"function_name\":{\"type\":\"string\"},\"arguments\":{}},\"required\":[\"function_name\",\"arguments\"]}},\"expires_at\":{\"type\":\"string\",\"format\":\"date-time\"}},\"required\":[\"type\",\"call_id\",\"calls\",\"expires_at\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"question\"]},\"call_id\":{\"type\":\"string\"},\"question\":{\"type\":\"string\"},\"choices\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"answers the model proposes; a free-text answer is always possible\"},\"multi_select\":{\"type\":\"boolean\",\"description\":\"whether several choices can be picked together\"},\"expires_at\":{\"type\":\"string\",\"format\":\"date-time\"}},\"required\":[\"type\",\"call_id\",\"question\",\"expires_at\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}", + "flow": "# Global draft flow instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Never invent a folder.\n- `write_flow` mirrors flow mode's `set_flow_json`: pass `path`, optional `summary`, optional `description`, required `modules`, and optional `schema`, `preprocessor_module`, `failure_module`, `groups`, and `notes`. `summary` and `description` are top-level flow metadata (not part of the compact value `patch_flow_json` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. When overwriting an existing flow, top-level flow settings (see below) are preserved from the current flow — use `patch_flow_json` to change them.\n- `read_workspace_item` returns a compact flow `value` object with `modules`, `schema`, `preprocessor_module`, `failure_module`, `groups`, `notes`, and any top-level flow settings that are set.\n- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with `patch_flow_json`: same_worker, preserve_step_tags, concurrent_limit, concurrency_key, concurrency_time_window_s, debounce_delay_s, debounce_key, debounce_args_to_accumulate, max_total_debouncing_time, max_total_debounces_amount, skip_expr, cache_ttl, cache_ignore_s3_path, delete_after_secs, flow_env, priority, early_return, chat_input_enabled. For example, `chat_input_enabled: true` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow.\n- `modules` contains normal sequential modules. Use top-level `preprocessor_module` and `failure_module` for special modules; do not put `preprocessor` or `failure` in `modules`.\n- Every module needs a stable unique `id` and a useful `summary` when the schema supports it.\n- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.\n- When writing rawscript module code, call `get_instructions` with `subject: \"script\"` and the rawscript language first.\n\n## Groups and notes\n\n- Add `groups` to any non-trivial flow without being asked; \"Organizing Flows: Groups and Notes\" in the reference below gives the fields and the rules the editor enforces. Pass `null` to `write_flow` to clear them, and the same for `notes`.\n- With `patch_flow_json`, edit `groups` and `notes` the same way as any other field — they appear as top-level keys in the compact flow value.\n\n## Compact view: how rawscript bodies surface in tool I/O\n\n- `read_workspace_item` and `patch_flow_json` operate on a **compact view** of the flow: every rawscript module's `value.content` is replaced with the placeholder `\"inline_script.\"` so inline script bodies don't bloat tool I/O. Schema, groups, notes, preprocessor_module and failure_module are all shown in this view.\n- Inline rawscript content is **not** part of the JSON `patch_flow_json` sees. Edits to inline bodies happen via dedicated tools:\n - `read_flow_module_code(path, module_id)` — returns the raw inline script content for one module.\n - `set_flow_module_code(path, module_id, code)` — overwrites that module's inline script content; saves to the draft.\n- Use `patch_flow_json` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups/notes. Use `set_flow_module_code` for changes inside a specific rawscript body.\n- When **adding a new rawscript module** with `patch_flow_json`, set its `content` to the placeholder `\"inline_script.\"` (matching the compact view) — never real code — then **immediately fill the body** with `set_flow_module_code(path, module_id, code)`. The module is saved with an empty body until you do; the tool result lists the module ids still awaiting code. A placeholder that names anything other than the module's own id or an existing rawscript module is rejected.\n- `write_flow` is for full overwrites / create-from-scratch. Its `modules`, `preprocessor_module`, and `failure_module` arguments are **non-compact** flow modules: inline each rawscript body directly in `content` by default. But if a body is long or quote/backslash-heavy enough that escaping it into the JSON string is error-prone — or if a `write_flow` call comes back with a JSON parse error — create that module with **empty content** (`\"content\": \"\"`) and fill it with `set_flow_module_code(path, module_id, code)`, whose `code` is a plain argument with no nested escaping. `write_flow` reports which modules still have empty bodies so you know what to fill.\n - When overwriting an **existing** flow, set `\"content\": \"inline_script.\"` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected.\n\n\n# Windmill flow authoring reference\n\n# Windmill Flow Building Guide\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Hard Structural Rules\n\nThese are strict Windmill schema rules. Follow them exactly.\n\n- `value.modules` is only for normal sequential steps\n- `value.preprocessor_module` and `value.failure_module` are special top-level fields inside `value`, not entries in `value.modules`\n- If a flow needs a preprocessor, create `value.preprocessor_module` with `id: preprocessor`\n- If a flow needs a failure handler, create `value.failure_module` with `id: failure`\n- Do NOT create regular modules inside `value.modules` named `preprocessor` or `failure`\n- `preprocessor_module` and `failure_module` only support `script` or `rawscript`\n- `preprocessor_module` runs before normal modules and cannot reference `results.*`\n- `failure_module` can use the `error` object with `error.message`, `error.step_id`, `error.name`, and `error.stack`\n- A flow whose `failure_module` runs still ends as failed, unless the handler returns an object with `recover: true`: the flow then ends as a success. This also holds when the failing step is inside a loop, a branch or a subflow. `recover` never changes which steps run; whether a loop or branch carries on past a failure is still decided by its own settings (`skip_failures`, `continue_on_error`)\n\nCorrect shape:\n\n```yaml\nvalue:\n preprocessor_module:\n id: preprocessor\n value:\n type: rawscript\n ...\n failure_module:\n id: failure\n value:\n type: rawscript\n ...\n modules:\n - id: process_event\n value:\n type: rawscript\n ...\n```\n\nIncorrect shape:\n\n```yaml\nvalue:\n modules:\n - id: preprocessor\n ...\n - id: process_event\n ...\n - id: failure\n ...\n```\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## AI Agent Modules\n\nAn `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped\nobject with an extra `value.tool_type`: `flowmodule` for a script or inline script tool (or a nested\nagent or AI decision), `mcp` for an MCP server tool, `websearch` for web search, `question` for a\nbuilt-in `ask_question` tool (the agent stops, without holding a worker, until someone answers from\nthe run page or the chat; optional `value.timeout` in seconds, default 1800). Set\n`requires_approval: true` on a `flowmodule` or `mcp` tool to have a person approve or reject each of\nits calls before it runs.\n\n```json\n{\n \"id\": \"support_agent\",\n \"summary\": \"AI agent for customer support\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"openai\", \"resource\": \"$res:f/ai_providers/openai\", \"model\": \"gpt-4o\" }\n },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.query\" },\n \"system_prompt\": { \"type\": \"static\", \"value\": \"You are a helpful assistant.\" }\n },\n \"tools\": [\n {\n \"id\": \"search_docs\",\n \"summary\": \"search_documentation\",\n \"description\": \"Search the product documentation. Use it whenever the user asks how a feature works.\",\n \"value\": {\n \"tool_type\": \"flowmodule\",\n \"type\": \"rawscript\",\n \"language\": \"bun\",\n \"content\": \"export async function main(query: string) { return ['doc1', 'doc2']; }\",\n \"input_transforms\": { \"query\": { \"type\": \"ai\" } }\n }\n }\n ]\n }\n}\n```\n\n- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script\n that runs the flow\n- Each argument of a tool is either the model's or fixed. `{ \"type\": \"ai\" }` (or no value) lets the\n model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix\n what the model must not choose, such as a resource, a table name or a limit\n- `provider` is an object, not a bare resource string: `{ \"kind\": ,\n \"resource\": \"$res:\", \"model\": }`. Required unless the module links to a saved\n agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its\n fields to flow inputs instead — see below\n\n### Chat-Mode Flows\n\nA flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer\nsends one message per turn and renders the conversation. It needs a required `user_message` string\ninput, read by the agent. Any other flow input the composer does not edit itself is asked for\nunder Configure inputs.\n\n**A static `provider` gives a chat that cannot change its model.** Feed it from flow inputs\ninstead, either way round: one input carrying the whole object (`\"expr\": \"flow_input.model_config\"`)\nmakes every field editable, or wire it field by field to fix some and expose others. A field the\nchat can write becomes a control in the composer — a provider picker, a model list, a thinking\ncontrol — and a field left static is fixed, with no control drawn for it. `kind` is the one\nexception: the composer writes it only together with `resource`, since a provider is picked as a\npair, so a `kind` input wired on its own stays askable under Configure inputs and nothing the run\nneeds becomes unreachable.\n\n```json\n{\n \"id\": \"chat_agent\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"javascript\",\n \"expr\": \"({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })\"\n },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.user_message\" },\n \"user_attachments\": { \"type\": \"javascript\", \"expr\": \"flow_input.files\" },\n \"memory\": { \"type\": \"static\", \"value\": { \"kind\": \"compaction\" } },\n \"streaming\": { \"type\": \"static\", \"value\": true },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" }\n },\n \"tools\": []\n }\n}\n```\n\n- Wiring field by field means one object literal whose values are literals or bare `flow_input.x`\n references. A spread, a call or a computed key leaves the composer unable to tell which input\n feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object\n is read instead as that one input carrying every field\n- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same\n two controls through its `model` and `reasoning_effort` inputs, which replace what the agent\n saved: `\"model\": { \"type\": \"javascript\", \"expr\": \"flow_input.model\" }`. An empty model keeps the\n saved one, an empty effort sends none, and the provider resource always stays the agent's\n- `memory` is what lets the agent see earlier turns; without it every message starts from nothing\n- `streaming` on makes the answer and its thinking appear token by token instead of all at once\n- `user_attachments` points at a flow input typed as an array of s3 objects\n (`{ \"type\": \"array\", \"items\": { \"type\": \"object\", \"resourceType\": \"s3object\" } }`), so files\n sent with a message reach the agent\n- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the\n conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat\n supplies it itself; a run driven any other way has to pass it or the server refuses the job\n\n### Tool Naming Rules\n\nThese rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` or `question`\ntool's `summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP\nserver's own tool names, so none is name-checked at all — leave those summaries as they are.\n\n- A flowmodule tool's `summary` is the **name the agent calls it by**, not a human label. Put the\n human-readable explanation in `description`\n- `summary` must match `^[a-zA-Z0-9_]+$`: letters, numbers and underscores only. No spaces, dashes,\n dots or accents — `search_documentation`, never `Search documentation`\n- Always set `summary`. It must be unique among that agent's tools, and must not be one of the\n reserved ids (`do`, `bg`, `ctx`, `state`, `if`, `else`, `for`, `delete`, `while`, `new`, `in`,\n `failure`, `preprocessor`, `as`, `Input`, `Result`, `Trigger`)\n- A tool name outside that character set fails any run that offers the tool to the agent, with `Invalid tool name`.\n The flow write tools refuse such a name.\n- Tool `id` follows the same rules as any module ID — unique across the flow, underscores not spaces\n- `description` is optional free text telling the agent when and how to call the tool. Set it\n whenever the name alone does not make that obvious; it overrides the description derived from the\n underlying script\n\n## AI Decision Modules\n\nAn `aidecision` module asks a decision model (TypeSafe's Jev, Cloudflare's Clef or OpenAI's Decisions API) typed questions about a `state` and\nanswers each with calibrated probabilities instead of text. Prefer it over an `aiagent` when the step\nis a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it\nis faster, cheaper, and its answers have a fixed shape.\n\n```json\n{\n \"id\": \"triage\",\n \"summary\": \"Classify the ticket\",\n \"value\": {\n \"type\": \"aidecision\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"typesafe\", \"resource\": \"$res:f/ai/typesafe\", \"model\": \"jev-latest\" }\n },\n \"state\": { \"type\": \"javascript\", \"expr\": \"flow_input.message\" },\n \"questions\": {\n \"type\": \"static\",\n \"value\": {\n \"intent\": {\n \"type\": \"choice\",\n \"instructions\": \"What does the customer want?\",\n \"criteria\": { \"refund\": \"Money back\", \"bug\": \"Something is broken\", \"other\": \"Anything else\" }\n },\n \"urgency\": {\n \"type\": \"score\",\n \"instructions\": \"How urgent is the ticket?\",\n \"criteria\": [\"Can wait\", \"This week\", \"Today\", \"Right now\"]\n },\n \"angry\": { \"type\": \"noul\", \"instructions\": \"Is the customer angry?\" }\n }\n }\n }\n }\n}\n```\n\n- `provider.kind` is `typesafe` (`model` `jev-latest` unless a version is pinned), `cloudflare`\n (`model` `clef`, or `clef-flash` for faster answers) or `openai` (`model` `gpt-6-luna`, never a\n chat model); the resource is of that same type\n- `state` is the content to evaluate: usually a text, such as the message to classify. To combine\n several values, pass an object with descriptive keys holding only what the questions need\n (`({ message: flow_input.message, plan: results.get_account.plan })`); an array of strings also works\n- `questions` maps each question name to `{ type, instructions, criteria }`:\n - `choice`: `criteria` maps each option to its description (up to 255 options). The answer has\n `choice`, `probabilities` and `confidence`\n - `score`: `criteria` is an ordered array of 2 to 10 level descriptions. The answer has `score`,\n `legend`, `probabilities` and `confidence`\n - `noul`: yes/no, `criteria` optionally `{ \"true\": ..., \"false\": ... }` descriptions. The answer has\n `noul`, the probability of yes from 0 to 1\n- The result is `{ output, model, usage }` with `output` keyed by question name, so a later step\n reads `results.triage.output.intent.choice`, `results.triage.output.urgency.score` or\n `results.triage.output.angry.noul > 0.7`\n- On `openai` the same `questions` are translated for its Decisions API and the answers read the\n same way (`choice`, `score`, `noul`), except that `probabilities` is a list of\n `{ value, probability }` (plus `label` on a score) and a score has no `legend`. A question OpenAI\n refuses to answer fails the step. A `state` that is\n an array of user messages (`[{ role: \"user\", content: [{ type: \"input_text\", text }, { type: \"input_image\", image_url: \"data:image/png;base64,...\" }] }]`)\n is sent as is, which is how an image is evaluated; any other object or array is sent as JSON text\n\n### Branching on the Answers\n\nTo route on the answers, put a `branchone` right after the decision, one branch per option, with\neach `expr` reading the decision by id:\n\n```json\n{\n \"id\": \"route\",\n \"value\": {\n \"type\": \"branchone\",\n \"branches\": [\n { \"summary\": \"refund\", \"expr\": \"results.triage.output.intent.choice === 'refund'\", \"modules\": [...] },\n { \"summary\": \"bug\", \"expr\": \"results.triage.output.intent.choice === 'bug'\", \"modules\": [...] }\n ],\n \"default\": [...]\n }\n}\n```\n\nFor a yes/no question, one branch with `\"expr\": \"results.triage.output.angry.noul >= 0.5\"` (tune the\nthreshold as needed) and the `default` as the \"no\" path.\n\n### As an Agent Tool\n\nAn `aidecision` can be a `flowmodule` tool of an `aiagent` (`\"tool_type\": \"flowmodule\", \"type\":\n\"aidecision\"`): set `state` to `{ \"type\": \"ai\" }` so the calling model supplies it, and only `output`\ngoes back to the model. The Tool Naming Rules apply to its `summary`. Only a flow's own agent step\ncan call one, not an agent used as a tool.\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n- AI agent flowmodule tool names with spaces - `summary` is the tool name and only accepts letters, numbers and underscores\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step only when that step result is in scope\n- `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope\n- `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`)\n- `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`)\n- After a suspend step (approval), the next step's javascript expressions also get:\n - `resume` - the last resume payload (the resume form values); with `continue_on_disapprove_timeout`, a disapproval or timeout yields `{ error: { name: \"SuspendedDisapproved\" | \"SuspendedTimedOut\", message } }` instead\n - `resumes` - every resume payload, in arrival order\n - `approvers` - a plain array of strings, one per resume: for a resume through the `resume`/`cancel` URL from `getResumeUrls(approver)`, the `approver` passed there if any; otherwise (including the `approvalPage` link) the resuming user's username, else `\"anonymous\"`, e.g. `approvers[0]`. It is NOT the `{ resume_id, approver }` objects stored in the flow status, so `approvers[0].approver` is always undefined\n\n## Loop Structure Rules\n\n- A `forloopflow` runs its `modules` once per element of `iterator`, a javascript expression returning an array (e.g. `results.get_items`); `parallel: true` runs the iterations concurrently, and `skip_failures: true` carries on past a failed iteration\n- For `whileloopflow`, break the loop with a module-level `stop_after_if`: on the loop module itself, or on an inner step (required when that step carries state via its own `results` — see below)\n- `stop_after_if` is always a sibling of `id` and `value` on a flow module — never a direct key of the loop's `value` object\n- `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition\n- `stop_after_if` is evaluated after each iteration: on the loop module, `result` is that iteration's result (what its last step returned); on an inner step, it is that step's result\n- `flow_input.iter.value` in a `whileloopflow` is just the iteration index (same number as `flow_input.iter.index`) — it never carries state, so `flow_input.iter.value.` is always undefined and a loop whose stop condition depends on it never terminates\n- To carry state across iterations, a step reads its own previous-iteration result via `results.` with a first-iteration fallback (e.g. `results.b ?? flow_input.start`) — but then the loop's `stop_after_if` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where `results.` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own `stop_after_if`, retry or similar, resolve `results` across iterations regardless of stop placement)\n- For state that is just a counter, derive it from the index instead (e.g. `flow_input.iter.index + 1`) — that works in every configuration, including with `stop_after_if` on the loop module\n- If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array\n\nCorrect `whileloopflow` shape:\n\n```yaml\n- id: loop_until_done\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n value:\n type: rawscript\n input_transforms:\n count:\n type: javascript\n expr: flow_input.iter.index + 1\n- id: return_final_state\n value:\n type: rawscript\n input_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done[results.loop_until_done.length - 1]\n```\n\nCorrect `whileloopflow` shape carrying state via `results` (stop condition on the inner step):\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: rawscript\n input_transforms:\n state:\n type: javascript\n expr: results.advance_state ?? flow_input.initial_state\n```\n\nIncorrect `whileloopflow` patterns:\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n stop_after_if:\n expr: result.done === true\n```\n\n```yaml\ninput_transforms:\n state:\n type: javascript\n # iter.value is a number (the iteration index); there is no previous-iteration state\n expr: flow_input.iter.value.count\n```\n\n```yaml\ninput_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done\n```\n\n## Approval / Suspend Structure\n\nAn approval step is a normal **script** step (`type: rawscript` or `type: script`) that is turned into an approval by adding a module-level `suspend`. Its script calls `wmill.getResumeUrls(approver)` to generate the secret resume/cancel URLs and returns them so they can be sent to the approver(s) (Slack, email, etc.) or approved from the run page.\n\n- `suspend` belongs on the flow module object itself, as a sibling of `id` and `value`\n- Never put `suspend` inside `value`\n- Do NOT use `type: identity` for an approval step. An identity step suspends but never produces the resume URLs, so approvers have no link to act on — it is not a functional approval.\n\nCorrect shape:\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n resume_form:\n schema:\n type: object\n properties:\n comment:\n type: string\n required: [comment]\n value:\n type: rawscript\n language: bun\n input_transforms:\n approver:\n type: static\n value: ''\n content: |\n import * as wmill from \"windmill-client\"\n\n export async function main(approver?: string) {\n const urls = await wmill.getResumeUrls(approver)\n // send urls.resume / urls.cancel to the approver(s), e.g. via Slack or email\n return urls\n }\n```\n\nIncorrect shape (suspend misplaced inside `value`):\n\n```yaml\n- id: request_approval\n value:\n type: rawscript\n suspend:\n required_events: 1\n```\n\nIncorrect shape (identity has no resume URLs — not a real approval):\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n value:\n type: identity\n```\n\n## Branch Result Scope Rules\n\n- A `branchone` runs the first of its `branches` whose `expr` is true, in order, and its `default` modules when none is; a `branchall` runs every branch (concurrently with `parallel: true`)\n- Inside a branch, you may reference earlier outer steps and earlier steps in the same branch\n- Outside a `branchone`, do NOT reference ids of steps that only exist inside its branches or default branch. Use `results.` instead\n- Outside a `branchall`, do NOT reference ids of steps inside its branches. Use `results.` instead\n- If downstream steps need a stable shape after a branch, make each branch return the same fields\n- When needed, add a normalization step immediately after the branch and consume `results.` there\n\nCorrect after `branchone`:\n\n```yaml\n- id: route_order\n value:\n type: branchone\n ...\n- id: send_confirmation\n value:\n input_transforms:\n routed:\n type: javascript\n expr: results.route_order\n```\n\nIncorrect after `branchone`:\n\n```yaml\nexpr: results.create_shipment\nexpr: results.create_backorder\n```\n\nCorrect after `branchall`:\n\n```yaml\n- id: enrich_parallel\n value:\n type: branchall\n parallel: true\n ...\n- id: combine_data\n value:\n input_transforms:\n enrichments:\n type: javascript\n expr: results.enrich_parallel\n```\n\n## Input Transforms\n\nEvery rawscript module needs `input_transforms` to map function parameters to values:\n\nStatic transform (fixed value):\n{\"param_name\": {\"type\": \"static\", \"value\": \"fixed_string\"}}\n\nJavaScript transform (dynamic expression):\n{\"param_name\": {\"type\": \"javascript\", \"expr\": \"results.previous_step.data\"}}\n\n## Resource References\n\n- For flow inputs: Use type `\"object\"` with format `\"resource-{type}\"` (e.g., `\"resource-postgresql\"`)\n- For step inputs: Use static value `\"$res:path/to/resource\"`\n\n## Reusing Existing Scripts and Flows\n\nUnless the user asked for new code, look for a workspace script or flow that already does a step's job before writing it, and reuse it by path instead of copying its logic into a rawscript:\n\n- a workspace script: `type: script` with `path` (e.g. `f/folder/send_email`)\n- a workspace flow, run as a subflow: `type: flow` with `path`\n- a Hub script: `type: script` with a `hub///` path\n\nThe step's `input_transforms` must cover the reused item's inputs, so read its input schema first.\n\n## Additional Prompt for AI\n\nA flow's input schema may carry a top-level `prompt_for_ai` string, as a script's can: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that flow, and keep it when you rewrite the schema.\n\n## Organizing Flows: Groups and Notes\n\nGroups and notes shape how a flow reads in the editor; neither changes what it does.\n\n**Segment every non-trivial flow into groups without waiting to be asked.** Whenever a flow has more than a couple of steps, or consecutive steps form a stage (\"fetch\", \"transform\", \"notify\"), put them in a group, and aim for every meaningful step to belong to one. Use notes sparingly, for flow-wide information that belongs to no span of steps: the flow's purpose, key assumptions, warnings, TODOs. One note is usually enough; never label a run of steps with a note, which is what a group is for.\n\n`value.groups` lists the groups, each spanning the steps from `start_id` to `end_id`:\n\n- `start_id`, `end_id` (required): ids of the group's first and last step; the same id for both makes a one-step group\n- `summary`: the group's title\n- `note`: markdown shown under the title\n- `color`: one of `yellow`, `blue`, `green`, `purple`, `pink`, `orange`, `red`, `cyan`, `lime`, `gray`, never a hex code or CSS color; leave it out and the editor picks one\n- `autocollapse`: `true` shows the group collapsed by default\n\nThe editor refuses to draw a flow whose groups break any of these rules:\n\n- `start_id` and `end_id` are steps of the same list: both top-level, or both in the same loop body or branch. A group can hold a loop or branch step whole, but cannot start outside one and end inside it\n- `start_id` does not come after `end_id` in that list\n- groups nest (one entirely inside another) but never partly overlap, and no two groups share both `start_id` and `end_id`\n- groups hold ordinary steps only: never `preprocessor`, `failure`, `Input`, `Result`, `Trigger`, or an AI agent's tools\n\n`value.notes` lists sticky notes, each with a unique `id`, markdown `text`, a `color` from the same list, and `type: free`. The `group` note type is deprecated; use `value.groups` instead.\nLeave a note's `position` and `size` out and they are filled in for you.\n\n## Final Structural Self-Check\n\nBefore finalizing a flow, verify:\n\n- any preprocessor is in `value.preprocessor_module`\n- any failure handler is in `value.failure_module`\n- any approval step has module-level `suspend`\n- no downstream step references inner branch step ids from outside the branch\n- every AI agent flowmodule tool has a unique `summary` made only of letters, numbers and underscores\n- every group starts and ends on steps of the same list, start before end, nesting without partial overlap\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\nTo accept an S3 object as flow input:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"object\",\n \"format\": \"resource-s3_object\",\n \"description\": \"File to process\"\n }\n }\n}\n```\n\n## Using Resources in Flows\n\nOn Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.\n\n### As Flow Input\n\nIn the flow schema, set the property type to `\"object\"` with format `\"resource-{type}\"`:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"database\": {\n \"type\": \"object\",\n \"format\": \"resource-postgresql\",\n \"description\": \"Database connection\"\n }\n }\n}\n```\n\n### When No Resource Type Fits\n\nA connection still comes in as one resource input, not as separate string inputs (a URL, a token) or a variable read inside a step: type the input `resource-c_` with a custom resource type holding the service's connection fields, its secret in a variable the resource references. The `c_` prefix of custom types keeps one apart from a hub type synced later under the same name.\n\n- 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.\n- Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it.\n- A plain variable is fine when the user asks for it.\n\n### As Step Input (Static Reference)\n\nReference a specific resource using `$res:` prefix:\n\n```json\n{\n \"database\": {\n \"type\": \"static\",\n \"value\": \"$res:f/folder/my_database\"\n }\n}\n```\n\n\n## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names.\"},\"on_behalf_of\":{\"type\":\"string\",\"description\":\"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"preserve_step_tags\":{\"type\":\"boolean\",\"description\":\"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag.\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the flow job's args, result and logs after this many seconds following job completion\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead.\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff: the n-th retry waits multiplier \\u00d7 seconds^n\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled.\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead.\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one.\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"},\"error_include_result\":{\"type\":\"boolean\",\"description\":\"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay after this step completes, before the next step starts (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the step's args, result and logs after this many seconds following job completion\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"},\"skin\":{\"type\":\"string\",\"enum\":[\"detailed\",\"minimal\"],\"description\":\"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"job_token_scopes\":{\"type\":\"array\",\"nullable\":true,\"items\":{\"type\":\"string\"},\"description\":\"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"azure_foundry\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\",\"typesafe\",\"cloudflare\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"},\"reasoning_effort\":{\"type\":\"string\",\"description\":\"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryWindow\":{\"type\":\"object\",\"description\":\"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n`memory_id`). Without a memory id the agent runs without memory.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"window\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Number of most recent messages to load and store. 0 turns memory off.\"}},\"required\":[\"kind\",\"context_length\"]},\"MemoryAuto\":{\"type\":\"object\",\"deprecated\":true,\"description\":\"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryCompaction\":{\"type\":\"object\",\"description\":\"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"compaction\"]},\"context_window\":{\"type\":\"integer\",\"description\":\"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"deprecated\":true,\"description\":\"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryWindow\"},{\"$ref\":\"#/components/schemas/MemoryCompaction\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"window\":\"#/components/schemas/MemoryWindow\",\"compaction\":\"#/components/schemas/MemoryCompaction\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"},{\"$ref\":\"#/components/schemas/AiDecision\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\",\"aidecision\":\"#/components/schemas/AiDecision\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"bunnative\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"rlang\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\",\"dbt\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'.\"},\"description\":{\"type\":\"string\",\"description\":\"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.\"},\"job_token_scopes\":{\"type\":\"array\",\"nullable\":true,\"items\":{\"type\":\"string\"},\"description\":\"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it.\"},\"requires_approval\":{\"type\":\"boolean\",\"description\":\"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run.\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"},{\"$ref\":\"#/components/schemas/QuestionToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\",\"question\":\"#/components/schemas/QuestionToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"QuestionToolValue\":{\"type\":\"object\",\"description\":\"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"question\"]},\"timeout\":{\"type\":\"integer\",\"description\":\"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800.\"}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\\nthe prompt; image output always needs it.\\n\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"memory_id\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the `memory_id` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\\nolder `auto` or `manual` memory reads neither history input.\\n\"},\"previous_messages\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while `memory` is off or absent: managed memory\\nignores it, and an older `auto` or `manual` memory reads neither history input.\\n\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"enabled_tools\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of strings naming which of the tools configured in `tools` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"},\"max_iterations\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n\"},\"model\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Only on a step linked to a saved agent (see `agent`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n`flow_input.model` to choose the model per run.\\n\"},\"reasoning_effort\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside `provider`.\\n\"}}},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)\"},\"omit_output_from_conversation\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.\"},\"agent\":{\"type\":\"string\",\"description\":\"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\\n\"},\"tool_inputs\":{\"type\":\"object\",\"description\":\"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n\",\"additionalProperties\":{\"type\":\"object\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}}},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"type\",\"input_transforms\"]},\"AiDecision\":{\"type\":\"object\",\"description\":\"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { : answer },\\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults..output..choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the decision mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"state\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n\"},\"questions\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n\"}},\"required\":[\"provider\",\"state\",\"questions\"]},\"type\":{\"type\":\"string\",\"enum\":[\"aidecision\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)\"}},\"required\":[\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"approval\"]},\"call_id\":{\"type\":\"string\"},\"calls\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"function_name\":{\"type\":\"string\"},\"arguments\":{}},\"required\":[\"function_name\",\"arguments\"]}},\"expires_at\":{\"type\":\"string\",\"format\":\"date-time\"}},\"required\":[\"type\",\"call_id\",\"calls\",\"expires_at\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"question\"]},\"call_id\":{\"type\":\"string\"},\"question\":{\"type\":\"string\"},\"choices\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"answers the model proposes; a free-text answer is always possible\"},\"multi_select\":{\"type\":\"boolean\",\"description\":\"whether several choices can be picked together\"},\"expires_at\":{\"type\":\"string\",\"format\":\"date-time\"}},\"required\":[\"type\",\"call_id\",\"question\",\"expires_at\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}", "resource": "# Global draft resource & variable instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- A resource draft is a workspace item: `{ type: 'resource', path, summary?, value, isDraft }`. `value` is a CreateResource body: `{ path, value, description?, resource_type, labels? }` where the inner `value` is the resource type's data shape.\n- Reading a variable returns `{ type: 'variable', path, summary?, isSecret, isDraft }` — never its value, secret or not. `isSecret` tells you whether the value is encrypted.\n- `write_variable` takes `{ path, value?, is_secret?, description?, is_oauth?, expires_at?, value_valid_until?, labels? }`. Creating a variable needs `value` and `is_secret`; editing one needs only the fields you are changing. Omitting `value` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any `value` you did not get from the user destroys it.\n- For secret fields in a resource value, create the variable with `write_variable` and `is_secret: true`, and deploy it before the resource (see \"Secrets\" in the reference below).\n- Reference formats inside resource values: `$var:g/all/name` (global), `$var:u/user/name` (user), `$var:f/folder/name` (folder). Reference another resource with `$res:path/to/resource`. The same strings are also how a resource or variable is passed as a run argument (see the run-argument rule in the resource reference below); what they are never valid as is a variable's own value.\n- Use `search_resource_types` to discover valid `resource_type` names and their JSON Schemas. Match the resource value to that schema.\n- For an agent (an `ai_agent` resource), call `get_instructions` with `subject: \"agent\"` instead.\n- For OAuth resources, the `is_oauth: true` flag is managed by Windmill's OAuth flow; global mode generally creates manual resources, not OAuth ones.\n\n# Windmill resource & variable reference\n\n# Windmill Resources\n\nResources store credentials and configuration for external services.\n\n## Resource Structure\n\n```json\n{\n \"value\": {\n \"host\": \"db.example.com\",\n \"port\": 5432,\n \"user\": \"admin\",\n \"password\": \"$var:g/all/db_password\",\n \"dbname\": \"production\"\n },\n \"description\": \"Production PostgreSQL database\",\n \"resource_type\": \"postgresql\"\n}\n```\n\n## Required Fields\n\n- `value` - Object containing the resource configuration\n- `resource_type` - Name of the resource type (e.g., \"postgresql\", \"slack\")\n\n## Agents\n\nAn agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the\nagent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by\nlinking its path.\n\n## Variable References\n\nReference variables in resource values:\n\n```json\n{\n \"value\": {\n \"api_key\": \"$var:g/all/api_key\",\n \"secret\": \"$var:u/admin/secret\"\n }\n}\n```\n\n**Reference formats:**\n- `$var:g/all/name` - Global variable\n- `$var:u/username/name` - User variable\n- `$var:f/folder/name` - Folder variable\n\n## Secrets\n\nNever put a secret (password, API key, token) inline in a resource value. Store it in a secret variable and reference that variable as `$var:`.\n\n- `$var:` is a reference, not a value: it resolves to the variable's value at run time. Never invent a value for a variable.\n- A resource that references a variable needs the variable to exist first, so create or deploy the variable before the resource.\n\n## Resource References\n\nReference other resources:\n\n```json\n{\n \"value\": {\n \"database\": \"$res:f/databases/postgres\"\n }\n}\n```\n\n## Passing a Resource or Variable as a Run Argument\n\nA script or flow argument typed as a resource (schema `format: resource-`) is passed as\nthe **bare string** `$res:` — the whole argument value. Same for a variable, with\n`$var:`. This applies everywhere job arguments are supplied: `wmill script run/preview`,\n`wmill flow run/preview`, the `runScriptByPath` / `runFlowByPath` API, a schedule's `args`, a\ntrigger's configured static args.\n\n```json\n{\n \"db\": \"$res:f/databases/postgres_prod\",\n \"api_token\": \"$var:g/all/api_token\"\n}\n```\n\nThe reference is resolved when the job runs, under the job's run-as identity — the caller for an\nordinary run, but the configured principal for a schedule, a trigger, or a runnable set to run on\nbehalf of someone else. The run fails if that identity cannot read the referenced resource or\nvariable.\n\n**Never wrap it in an object.** The resolver only rewrites a JSON value that *is* a string\nstarting with `$res:` / `$var:`; keys are never inspected. These are all wrong and are passed\nthrough to the script unchanged:\n\n```json\n{ \"db\": { \"$res\": \"f/databases/postgres_prod\" } }\n{ \"db\": { \"resource\": \"f/databases/postgres_prod\" } }\n{ \"db\": \"f/databases/postgres_prod\" }\n```\n\nThe string may sit anywhere a string can — a top-level argument, a nested object field\n(`{ \"gh_auth\": { \"token\": \"$var:g/all/gh_token\" } }`), or an array element (array elements are\nwalked only while nested at most two levels deep, and only for arrays of at most 1000 items).\nThe prefix must be on the string itself.\n\n## Common Resource Types\n\n### PostgreSQL\n```json\n{\n \"resource_type\": \"postgresql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 5432,\n \"user\": \"postgres\",\n \"password\": \"$var:g/all/pg_password\",\n \"dbname\": \"windmill\",\n \"sslmode\": \"prefer\"\n }\n}\n```\n\n### MySQL\n```json\n{\n \"resource_type\": \"mysql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 3306,\n \"user\": \"root\",\n \"password\": \"$var:g/all/mysql_password\",\n \"database\": \"myapp\"\n }\n}\n```\n\n### Slack\n```json\n{\n \"resource_type\": \"slack\",\n \"value\": {\n \"token\": \"$var:g/all/slack_token\"\n }\n}\n```\n\n### AWS S3\n```json\n{\n \"resource_type\": \"s3\",\n \"value\": {\n \"bucket\": \"my-bucket\",\n \"region\": \"us-east-1\",\n \"accessKeyId\": \"$var:g/all/aws_access_key\",\n \"secretAccessKey\": \"$var:g/all/aws_secret_key\"\n }\n}\n```\n\n### HTTP/API\n```json\n{\n \"resource_type\": \"http\",\n \"value\": {\n \"baseUrl\": \"https://api.example.com\",\n \"headers\": {\n \"Authorization\": \"Bearer $var:g/all/api_token\"\n }\n }\n}\n```\n\n### Kafka\n```json\n{\n \"resource_type\": \"kafka\",\n \"value\": {\n \"brokers\": \"broker1:9092,broker2:9092\",\n \"sasl_mechanism\": \"PLAIN\",\n \"security_protocol\": \"SASL_SSL\",\n \"username\": \"$var:g/all/kafka_user\",\n \"password\": \"$var:g/all/kafka_password\"\n }\n}\n```\n\n### NATS\n```json\n{\n \"resource_type\": \"nats\",\n \"value\": {\n \"servers\": [\"nats://localhost:4222\"],\n \"user\": \"$var:g/all/nats_user\",\n \"password\": \"$var:g/all/nats_password\"\n }\n}\n```\n\n### MQTT\n```json\n{\n \"resource_type\": \"mqtt\",\n \"value\": {\n \"host\": \"mqtt.example.com\",\n \"port\": 8883,\n \"username\": \"$var:g/all/mqtt_user\",\n \"password\": \"$var:g/all/mqtt_password\",\n \"tls\": true\n }\n}\n```\n\n## When No Resource Type Fits\n\nThe Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service:\n\n- If the instance is reported as not having synced the hub's types, mention that a superadmin can sync them: the hub most likely has one for the service. That holds for a type a hub script takes too: it is missing here until the sync, so say so before asking for a resource of it. Create one of your own only if the user still wants it, since after a sync the workspace would hold both.\n- Otherwise, or when the user prefers it to syncing, propose a custom resource type named `c_` holding the service's connection fields (base URL, account or region identifiers, credentials), as in \"Custom Resource Types\" below, and ask the user before creating it: every resource of that type and every script taking one depends on its fields.\n- A plain variable instead of a resource is fine when the user asks for it; without that, default to the resource, its secret in a variable it references.\n\n## Custom Resource Types\n\nA custom type's name starts with `c_`: the hub's types reach every instance under their plain names, so the prefix keeps a custom type from clashing with one synced later. Scripts and flows refer to the full name: `format: resource-c_custom_api`, `RT.CCustomApi` in TypeScript, a `c_custom_api` TypedDict in Python.\n\nCreate custom resource types with JSON Schema:\n\n```json\n{\n \"name\": \"c_custom_api\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"base_url\": {\"type\": \"string\", \"format\": \"uri\"},\n \"api_key\": {\"type\": \"string\"},\n \"timeout\": {\"type\": \"integer\", \"default\": 30}\n },\n \"required\": [\"base_url\", \"api_key\"]\n },\n \"description\": \"Custom API connection\"\n}\n```\n\nSave as: `c_custom_api.resource-type.json`\n\n## OAuth Resources\n\nOAuth resources are managed through the Windmill UI and marked:\n\n```json\n{\n \"is_oauth\": true,\n \"account\": 123\n}\n```\n\nOAuth tokens are automatically refreshed by Windmill.\n\n## Using Resources in Scripts\n\n### TypeScript (Bun/Deno)\n```typescript\nexport async function main(db: RT.Postgresql) {\n // db contains the resource values\n const { host, port, user, password, dbname } = db;\n}\n```\n\n### Python\n```python\nclass postgresql(TypedDict):\n host: str\n port: int\n user: str\n password: str\n dbname: str\n\ndef main(db: postgresql):\n # db contains the resource values\n pass\n```\n\n", "app": "# Global draft app instructions\n\n- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.\n- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to `u//` for bare names; only use `f//` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.\n- To create a new app, use `init_app` with a path, optional summary, a framework (`react19` / `react18` / `svelte5`) and, for an app that stores data, its `data` setup. \"Starting a new app\" below decides the framework and when the data setup is asked. Ask the user each of its questions, suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool. `init_app` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.\n- `init_app` seeds the starter inline runnables `greet`, `word_stats`, `slow_task`, `stream_text` so the demo tour works on first render. Replace or remove them once you start building real backend runnables.\n- Frontend file paths start with `/` (e.g. `/index.tsx`, `/App.tsx`, `/styles.css`). Use `write_app_file` / `patch_app_file` / `delete_app_file`.\n- Backend inline runnables are addressed as `backend//main.{ts|py}` from the file tools, but you create or update them via `write_app_runnable` / `delete_app_runnable` (which take the runnable shape directly: `{ name, type, inlineScript?, path?, staticInputs? }`).\n- `/wmill.d.ts` (or `wmill.ts`) is generated automatically from the backend runnables — never write it directly.\n- Inline runnables only support `bun` or `python3` in chat. Path runnables (`script`/`flow`/`hubscript`) reference an existing item.\n- When a path runnable points at a draft (see \"Draft code vs deployed code\" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.\n- The authoring reference below carries the TypeScript SDK. For a `python3` runnable, call `get_instructions` again with `subject: \"app\"` and `language: \"python3\"`.\n- Use `deploy_workspace_item` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.\n- Use `read_workspace_item` with `type: 'app'` for a metadata summary (file paths and runnable list, no contents). Use `read_app_file` to read an individual file; large files are truncated to a head slice, so pass `offset`/`limit` to page through the rest rather than re-reading the whole file.\n- To find where a symbol or string lives across the app, call `search_app` (greps every frontend file and inline runnable, returns matching `file:line` rows) instead of reading files one by one — then `read_app_file` only the ranges you need. The loop is list (`read_workspace_item`) → locate (`search_app`) → inspect (`read_app_file` with `offset`/`limit`).\n\n# Windmill raw app authoring reference\n\n# Windmill Raw Apps\n\nRaw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.\n\n## App shape\n\nA raw app has three logical parts:\n\n- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc.\n- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key.\n- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.\n\n## Starting a new app\n\nA new app gets the setup Windmill's own new-app dialog gives one: a framework, then where its data lives.\n\n### Framework\n\nUse the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.\n\n### Data setup\n\nWhen a new app has to store data and the user did not say where, **ask before creating anything**: no app, no schema, no table. Where the data lives is the user's decision, and tables created in the wrong place stay there. Look up the workspace's data tables first so every choice you offer exists, then ask, as proposed answers rather than open questions:\n\n1. **Data table**: which one to use, the default first (`main` when it exists, otherwise the first listed). Skip this one when the workspace has a single data table.\n2. **Schema**: where the app's new tables go.\n - A **new schema**, the usual choice, offered first. Suggest the first unused name among `app1`, `app2`, …\n - An **existing schema** of that data table, by name.\n - **None**: the tables go in `public`.\n3. **Tables**: only when that data table already holds tables the app could use, whether to **reuse** them (name them) or **create new ones**.\n\nDo not ask when:\n\n- the user already said where the data goes. A part they left out takes its default above: the default data table, a new `appN` schema, new tables;\n- the app, or its saved draft, already carries a data config (`data.datatable`, `data.schema` or `data.tables`). That config is the answer;\n- the app stores nothing.\n\nWith no data table in the workspace there is nothing to choose: say that one has to be configured in the workspace settings before the app can store data.\n\nA question left unanswered or dismissed is not a go-ahead for the defaults: stop there and say what is still needed.\n\nThen build what was chosen: the data table and schema become the app's `data.datatable` and `data.schema`, the tables it uses, reused or new, go in `data.tables`, and a new schema is created (`CREATE SCHEMA IF NOT EXISTS`) before the tables that go in it.\n\n### An app that arrives already set up\n\nAn app the user just started from the new-app dialog is already set up: its files are the framework template it was started with, and a `data` config it carries (`data.datatable`, `data.schema` or `data.tables`) is the data setup it was started with. Build that app in place, with that data config, and in that framework unless the user now names another. Do not ask for either, and do not create a second app. Only when it carries no data config and has to store data is the data setup still to ask.\n\n## Frontend\n\n### Entrypoint\n\nThe entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `
` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.\n\nReact (`index.tsx`):\n\n```tsx\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\ncreateRoot(document.getElementById('root')!).render()\n```\n\nSvelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.\n\n**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.\n\n**Set the browser tab title with `document.title = '...'`** (or a `` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.\n\n**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.\n\n### Generated bindings (`wmill.d.ts` / `wmill.ts`)\n\nThe frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.\n\n### Calling backend runnables\n\nImport the generated bindings and call the runnable like a function. `./wmill` is the **only** way the frontend reaches anything server-side — datatables, workspace items, external services. Never `fetch` the Windmill API from frontend code: the bundle holds no token and builds no API URL.\n\n| Export | Resolves to | Use it for |\n|---|---|---|\n| `backend.<key>(args)` | the runnable's result | the default — run and wait |\n| `backendAsync.<key>(args)` | the **job id** (a string) | long-running work you want to track |\n| `waitJob(jobId)` | the job's **result** (rejects if the job failed) | awaiting a `backendAsync` job |\n| `getJob(jobId)` | a `Job` (`{ type, success, result, duration_ms, ... }`) | polling status without blocking |\n| `streamJob(jobId, onUpdate?)` | the final result, calling `onUpdate` per chunk | showing output as it is produced |\n\nA runnable is always called with **one object** whose keys are its `main` parameters — `main(user_id: string, limit: number)` is called as `backend.get_users({ user_id, limit })`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the `wmill` client are paths (`u/<user>/<name>` or `f/<folder>/<name>`).\n\nRun and wait — the common case:\n\n```tsx\nimport { backend } from './wmill';\n\nconst user = await backend.get_user({ user_id: '123' });\n```\n\nStart a long job, then await it:\n\n```tsx\nimport { backendAsync, waitJob } from './wmill';\n\nconst jobId = await backendAsync.run_report({ month: '2026-08' }); // a string\nconst report = await waitJob(jobId); // the result itself\n```\n\nOr poll it without blocking, to render progress:\n\n```tsx\nimport { getJob } from './wmill';\n\nconst job = await getJob(jobId);\nif (job.type === 'CompletedJob') setReport(job.result);\n```\n\n`backendAsync` resolves a job id and nothing else — guard on it before storing or polling. A poll loop started on an `undefined` id never completes and shows as a row stuck \"running\" forever:\n\n```tsx\nconst jobId = await backendAsync.run_report(args);\nif (!jobId) throw new Error('run_report did not start a job');\n```\n\n**Never hand-write a job-polling runnable.** A backend runnable that calls `jobs/list`, or that returns `getResultMaybe(...)` for the frontend to poll, reimplements `backendAsync` + `waitJob` / `getJob` / `streamJob` — and it is what leads to guessing at base URLs and tokens.\n\n### Keeping data out of recorded demos\n\nAn app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:\n\n```tsx\n<label data-wm-no-record>\n Customer SSN <input value={ssn} onChange={onSsn} />\n</label>\n```\n\nApply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.\n\n### Chat UIs over a flow in chat mode or a saved agent\n\nA flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\n\nconst chat = useWindmillChat({ flowPath: 'f/support/assistant' })\n// chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop()\n```\n\n`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.\n\n#### A saved agent: an `agent` runnable\n\nChat with a saved AI agent (an `ai_agent` resource) through an `agent` runnable, a path runnable whose `path` is the agent's. Each call runs one turn of the agent's chat as the app, so a sandboxed app declares no frontend SDK scope, viewers get no consent prompt and need no access to the agent. Prefer it in every app, sandboxed or not:\n\n```json\n{ \"chat\": { \"type\": \"agent\", \"path\": \"f/support/agent\" } }\n```\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\nimport * as wmill from './wmill'\n\nconst chat = useWindmillChat({ wmill, runnable: 'chat' })\n```\n\n`runnable` is the runnable's key; each runnable keeps its own conversations. The runnable takes `user_message`; the chat passes the conversation id as `backendAsync.<key>(args, { memoryId })`: the agent remembers earlier turns of a conversation, and a signed-in viewer's conversations are theirs alone. Called directly as `backend.<key>({ user_message })`, it is a single run with no conversation, for a one-off question; `backend.<key>(args, { memoryId })` waits for the reply of a conversation turn. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes `previous_messages` (chat messages) instead, and the app keeps the history itself. The chat keeps its transcript in the app's own storage. `chat.stop()` stops following without cancelling the turn, and a message sent while it still runs is refused with `TurnRunningError`. Attachments are not supported this way.\n\n## Backend runnables\n\nEach runnable has a unique key (used to call it from the frontend) and one of five types:\n\n| Type | What it is |\n|---|---|\n| `inline` | Custom code stored on the app itself. Most common for app-specific logic. |\n| `script` | Reference to an existing workspace script by path. |\n| `flow` | Reference to an existing workspace flow by path. |\n| `hubscript` | Reference to a hub script by path. |\n| `agent` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). |\n\n### Inline runnables\n\nInline runnables carry their own source code, and must expose a `main` function as their entrypoint.\n\n**TypeScript example** (runnable `get_user`):\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable();\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n return user;\n}\n```\n\n**Python example** (runnable `get_user`):\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable()\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n return user\n```\n\n#### Sharing code between runnables\n\nAn inline runnable can import another inline runnable of the same app by its id, as a relative import: `import { getDb } from './shared'` (TypeScript, Bun) or `from .shared import get_db` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no `main`; the frontend just never calls it. A TypeScript runnable whose id is `main` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id.\n\n#### The `wmill` client is already authenticated\n\nAn inline runnable runs as an ordinary Windmill job. `import * as wmill from 'windmill-client'` (TypeScript) and `import wmill` (Python) are already pointed at this instance and this workspace — there is nothing to configure.\n\n**Don't read `WM_TOKEN` or `BASE_INTERNAL_URL` and build an API URL to `fetch`.** The client's own `setClient` already reads exactly those, and it also sets the credentials mode a raw app needs (`WM_RAW_APP` suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with `Access-Control-Allow-Origin: *`). Rebuilding that by hand drops the parts you can't see. Use `wmill.*` for everything Windmill, and `fetch` only for third-party APIs.\n\nUse only `wmill` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (`JobService`, `ScriptService`, ...) are importable from `windmill-client`. What is not available is a name you guessed at: `getBaseUrl` and `getWorkspaceToken` are inventions, not API.\n\n### Path runnables (script / flow / hubscript / agent)\n\nWhen `type` is `script`, `flow`, `hubscript` or `agent`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface; an agent's is `user_message` and `user_attachments`, plus `previous_messages` when its memory is off.\n\nBefore writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at `hub/<version>/<app>/<name>`) for a third-party service, and reference it instead of copying its logic.\n\n### Draft code vs deployed code\n\nThis decides whether an app works before anything is deployed:\n\n- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.\n- **Path runnables (`script` / `flow` / `hubscript` / `agent`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.\n\nSo an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.\n\nThat makes the fix a one-item deploy, not a release. Offer to deploy exactly the referenced flow or script and leave the app a draft the user keeps testing in the preview — do not push the whole change set through the review-and-deploy page, and do not ask the user to deploy the app, unless they said they want to ship it.\n\nDo NOT quietly reimplement the flow inside an inline runnable to dodge the deployment: that leaves the user with two copies of the same logic and an app that ignores the flow they asked for. Inline the logic only when the user actually wants it inline.\n\nPrefer a **path runnable of type `flow`** over an inline runnable that calls `wmill.runFlowAsync`. The path runnable gives the frontend the flow's real input schema and works with `backend` / `backendAsync` / `waitJob` like any other runnable; a hand-written wrapper gives up all of that.\n\n### Static inputs\n\n`staticInputs` is an optional `Record<string, any>` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller.\n\n## Who can open a deployed app\n\nA draft app is reachable by nobody; deploying is what exposes it, and its backend runnables with it. Otherwise only users with access to the app can open it, unless the app is opened to:\n\n- **anonymous** users: anyone with the URL, without logging in;\n- **guests**: anyone the instance's identity provider authenticates, whether or not they belong to the workspace.\n\nEither one lets those people run the app's backend runnables, so never open an app up unless the user asks.\nWhen a deploy widens who may open the app, tell the user in plain words.\n\n## Sandbox isolation and frontend API access\n\nNew apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them.\n\nInside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.\nYou cannot turn isolation off; the user does it in the app's deploy settings.\n\n## Reading data as the viewer\n\nA backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features.\nSet it with `viewerDataAccess: true` on the backend runnable.\n\n## Data Tables\n\nData tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.\n\n### Critical rules\n\n1. **Check what exists first**: look up the workspace's data tables and their tables before designing storage, and reuse a suitable table rather than creating another. Never assume a `main` data table exists.\n2. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Queries against unlisted tables fail at runtime, so register a new table there before using it.\n3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.\n4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.\n5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.\n6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see \"Reading data as the viewer\"). Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no `data.roles` entry. Data table roles are an Enterprise Edition feature, self-hosted only.\n\n### Querying in TypeScript (Bun/Deno)\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable(); // Or: wmill.datatable('other_datatable')\n\n // Parameterized queries (safe from SQL injection)\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n const users = await sql`SELECT * FROM users WHERE active = ${true}`.fetch();\n\n // Insert/Update\n await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`.execute();\n await sql`UPDATE users SET name = ${newName} WHERE id = ${user_id}`.execute();\n\n return user;\n}\n```\n\n### Querying in Python\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable() # Or: wmill.datatable('other_datatable')\n\n # Use $1, $2, etc. for parameters\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()\n\n # Insert/Update\n db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email).execute()\n db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id).execute()\n\n return user\n```\n\n## Best Practices\n\n1. **Check existing tables** before creating new ones — reuse beats schema growth.\n2. **Use parameterized queries** — never concatenate user input into SQL.\n3. **Terminate every datatable statement** — the tagged template and `db.query(...)` only build a statement. It runs when you call `fetch` / `fetchOne` / `fetchOneScalar` / `execute` (`fetch` / `fetch_one` / `fetch_one_scalar` / `execute` in Python). An INSERT or UPDATE without one writes nothing and raises nothing. Awaiting the statement itself is a no-op — it is not a promise.\n4. **Keep runnables focused** — one function per runnable; small surface area.\n5. **Use descriptive keys** — `get_user`, not `a`.\n6. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.\n7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.\n8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable.\n9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.\n10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow or a saved agent** — never hand-roll a runnable that runs it and polls its stream; a saved agent is an `agent` runnable passed as `{ wmill, runnable }`.\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise<any>\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise<string>\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable<string>): Promise<void>\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise<any>\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise<any>\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise<any>\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise<string>\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string>\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string>\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record<string, any> | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true, tag: string | null = null): Promise<string>\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise<any>\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise<void>\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise<void>\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise<void>\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise<number | null>\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise<boolean>\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise<void>\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise<any>\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise<any>\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise<string>\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise<void>\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise<string>\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise<any>\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise<any>\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise<DenoS3LightClientSettings>\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Uint8Array | undefined>\n\n/**\n * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Blob | undefined>\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise<S3Object>\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise<void>\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object[]>\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object>\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string[]>\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string>\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise<string>\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise<void>} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise<void>\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise<void>} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise<void>\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow<T>(fn: (...args: any[]) => Promise<T>): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?: { concurrency?: number },): Promise<R[]>\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement<any>\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit<DucklakeMaterializeOptions, \"uniqueKey\">,): SqlStatement<any>\n", "app.python3": "# Global draft app instructions\n\n- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.\n- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to `u/<current-user>/<name>` for bare names; only use `f/<folder>/<name>` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.\n- To create a new app, use `init_app` with a path, optional summary, a framework (`react19` / `react18` / `svelte5`) and, for an app that stores data, its `data` setup. \"Starting a new app\" below decides the framework and when the data setup is asked. Ask the user each of its questions, suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool. `init_app` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.\n- `init_app` seeds the starter inline runnables `greet`, `word_stats`, `slow_task`, `stream_text` so the demo tour works on first render. Replace or remove them once you start building real backend runnables.\n- Frontend file paths start with `/` (e.g. `/index.tsx`, `/App.tsx`, `/styles.css`). Use `write_app_file` / `patch_app_file` / `delete_app_file`.\n- Backend inline runnables are addressed as `backend/<key>/main.{ts|py}` from the file tools, but you create or update them via `write_app_runnable` / `delete_app_runnable` (which take the runnable shape directly: `{ name, type, inlineScript?, path?, staticInputs? }`).\n- `/wmill.d.ts` (or `wmill.ts`) is generated automatically from the backend runnables — never write it directly.\n- Inline runnables only support `bun` or `python3` in chat. Path runnables (`script`/`flow`/`hubscript`) reference an existing item.\n- When a path runnable points at a draft (see \"Draft code vs deployed code\" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.\n- The authoring reference below carries the Python SDK, for `python3` inline runnables. For a `bun` runnable, call `get_instructions` again with `subject: \"app\"` and no language.\n- Use `deploy_workspace_item` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.\n- Use `read_workspace_item` with `type: 'app'` for a metadata summary (file paths and runnable list, no contents). Use `read_app_file` to read an individual file; large files are truncated to a head slice, so pass `offset`/`limit` to page through the rest rather than re-reading the whole file.\n- To find where a symbol or string lives across the app, call `search_app` (greps every frontend file and inline runnable, returns matching `file:line` rows) instead of reading files one by one — then `read_app_file` only the ranges you need. The loop is list (`read_workspace_item`) → locate (`search_app`) → inspect (`read_app_file` with `offset`/`limit`).\n\n# Windmill raw app authoring reference\n\n# Windmill Raw Apps\n\nRaw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.\n\n## App shape\n\nA raw app has three logical parts:\n\n- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc.\n- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key.\n- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.\n\n## Starting a new app\n\nA new app gets the setup Windmill's own new-app dialog gives one: a framework, then where its data lives.\n\n### Framework\n\nUse the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.\n\n### Data setup\n\nWhen a new app has to store data and the user did not say where, **ask before creating anything**: no app, no schema, no table. Where the data lives is the user's decision, and tables created in the wrong place stay there. Look up the workspace's data tables first so every choice you offer exists, then ask, as proposed answers rather than open questions:\n\n1. **Data table**: which one to use, the default first (`main` when it exists, otherwise the first listed). Skip this one when the workspace has a single data table.\n2. **Schema**: where the app's new tables go.\n - A **new schema**, the usual choice, offered first. Suggest the first unused name among `app1`, `app2`, …\n - An **existing schema** of that data table, by name.\n - **None**: the tables go in `public`.\n3. **Tables**: only when that data table already holds tables the app could use, whether to **reuse** them (name them) or **create new ones**.\n\nDo not ask when:\n\n- the user already said where the data goes. A part they left out takes its default above: the default data table, a new `appN` schema, new tables;\n- the app, or its saved draft, already carries a data config (`data.datatable`, `data.schema` or `data.tables`). That config is the answer;\n- the app stores nothing.\n\nWith no data table in the workspace there is nothing to choose: say that one has to be configured in the workspace settings before the app can store data.\n\nA question left unanswered or dismissed is not a go-ahead for the defaults: stop there and say what is still needed.\n\nThen build what was chosen: the data table and schema become the app's `data.datatable` and `data.schema`, the tables it uses, reused or new, go in `data.tables`, and a new schema is created (`CREATE SCHEMA IF NOT EXISTS`) before the tables that go in it.\n\n### An app that arrives already set up\n\nAn app the user just started from the new-app dialog is already set up: its files are the framework template it was started with, and a `data` config it carries (`data.datatable`, `data.schema` or `data.tables`) is the data setup it was started with. Build that app in place, with that data config, and in that framework unless the user now names another. Do not ask for either, and do not create a second app. Only when it carries no data config and has to store data is the data setup still to ask.\n\n## Frontend\n\n### Entrypoint\n\nThe entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id=\"root\">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.\n\nReact (`index.tsx`):\n\n```tsx\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\ncreateRoot(document.getElementById('root')!).render(<App />)\n```\n\nSvelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.\n\n**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.\n\n**Set the browser tab title with `document.title = '...'`** (or a `<title>` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.\n\n**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.\n\n### Generated bindings (`wmill.d.ts` / `wmill.ts`)\n\nThe frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.\n\n### Calling backend runnables\n\nImport the generated bindings and call the runnable like a function. `./wmill` is the **only** way the frontend reaches anything server-side — datatables, workspace items, external services. Never `fetch` the Windmill API from frontend code: the bundle holds no token and builds no API URL.\n\n| Export | Resolves to | Use it for |\n|---|---|---|\n| `backend.<key>(args)` | the runnable's result | the default — run and wait |\n| `backendAsync.<key>(args)` | the **job id** (a string) | long-running work you want to track |\n| `waitJob(jobId)` | the job's **result** (rejects if the job failed) | awaiting a `backendAsync` job |\n| `getJob(jobId)` | a `Job` (`{ type, success, result, duration_ms, ... }`) | polling status without blocking |\n| `streamJob(jobId, onUpdate?)` | the final result, calling `onUpdate` per chunk | showing output as it is produced |\n\nA runnable is always called with **one object** whose keys are its `main` parameters — `main(user_id: string, limit: number)` is called as `backend.get_users({ user_id, limit })`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the `wmill` client are paths (`u/<user>/<name>` or `f/<folder>/<name>`).\n\nRun and wait — the common case:\n\n```tsx\nimport { backend } from './wmill';\n\nconst user = await backend.get_user({ user_id: '123' });\n```\n\nStart a long job, then await it:\n\n```tsx\nimport { backendAsync, waitJob } from './wmill';\n\nconst jobId = await backendAsync.run_report({ month: '2026-08' }); // a string\nconst report = await waitJob(jobId); // the result itself\n```\n\nOr poll it without blocking, to render progress:\n\n```tsx\nimport { getJob } from './wmill';\n\nconst job = await getJob(jobId);\nif (job.type === 'CompletedJob') setReport(job.result);\n```\n\n`backendAsync` resolves a job id and nothing else — guard on it before storing or polling. A poll loop started on an `undefined` id never completes and shows as a row stuck \"running\" forever:\n\n```tsx\nconst jobId = await backendAsync.run_report(args);\nif (!jobId) throw new Error('run_report did not start a job');\n```\n\n**Never hand-write a job-polling runnable.** A backend runnable that calls `jobs/list`, or that returns `getResultMaybe(...)` for the frontend to poll, reimplements `backendAsync` + `waitJob` / `getJob` / `streamJob` — and it is what leads to guessing at base URLs and tokens.\n\n### Keeping data out of recorded demos\n\nAn app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:\n\n```tsx\n<label data-wm-no-record>\n Customer SSN <input value={ssn} onChange={onSsn} />\n</label>\n```\n\nApply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.\n\n### Chat UIs over a flow in chat mode or a saved agent\n\nA flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\n\nconst chat = useWindmillChat({ flowPath: 'f/support/assistant' })\n// chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop()\n```\n\n`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.\n\n#### A saved agent: an `agent` runnable\n\nChat with a saved AI agent (an `ai_agent` resource) through an `agent` runnable, a path runnable whose `path` is the agent's. Each call runs one turn of the agent's chat as the app, so a sandboxed app declares no frontend SDK scope, viewers get no consent prompt and need no access to the agent. Prefer it in every app, sandboxed or not:\n\n```json\n{ \"chat\": { \"type\": \"agent\", \"path\": \"f/support/agent\" } }\n```\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\nimport * as wmill from './wmill'\n\nconst chat = useWindmillChat({ wmill, runnable: 'chat' })\n```\n\n`runnable` is the runnable's key; each runnable keeps its own conversations. The runnable takes `user_message`; the chat passes the conversation id as `backendAsync.<key>(args, { memoryId })`: the agent remembers earlier turns of a conversation, and a signed-in viewer's conversations are theirs alone. Called directly as `backend.<key>({ user_message })`, it is a single run with no conversation, for a one-off question; `backend.<key>(args, { memoryId })` waits for the reply of a conversation turn. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes `previous_messages` (chat messages) instead, and the app keeps the history itself. The chat keeps its transcript in the app's own storage. `chat.stop()` stops following without cancelling the turn, and a message sent while it still runs is refused with `TurnRunningError`. Attachments are not supported this way.\n\n## Backend runnables\n\nEach runnable has a unique key (used to call it from the frontend) and one of five types:\n\n| Type | What it is |\n|---|---|\n| `inline` | Custom code stored on the app itself. Most common for app-specific logic. |\n| `script` | Reference to an existing workspace script by path. |\n| `flow` | Reference to an existing workspace flow by path. |\n| `hubscript` | Reference to a hub script by path. |\n| `agent` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). |\n\n### Inline runnables\n\nInline runnables carry their own source code, and must expose a `main` function as their entrypoint.\n\n**TypeScript example** (runnable `get_user`):\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable();\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n return user;\n}\n```\n\n**Python example** (runnable `get_user`):\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable()\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n return user\n```\n\n#### Sharing code between runnables\n\nAn inline runnable can import another inline runnable of the same app by its id, as a relative import: `import { getDb } from './shared'` (TypeScript, Bun) or `from .shared import get_db` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no `main`; the frontend just never calls it. A TypeScript runnable whose id is `main` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id.\n\n#### The `wmill` client is already authenticated\n\nAn inline runnable runs as an ordinary Windmill job. `import * as wmill from 'windmill-client'` (TypeScript) and `import wmill` (Python) are already pointed at this instance and this workspace — there is nothing to configure.\n\n**Don't read `WM_TOKEN` or `BASE_INTERNAL_URL` and build an API URL to `fetch`.** The client's own `setClient` already reads exactly those, and it also sets the credentials mode a raw app needs (`WM_RAW_APP` suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with `Access-Control-Allow-Origin: *`). Rebuilding that by hand drops the parts you can't see. Use `wmill.*` for everything Windmill, and `fetch` only for third-party APIs.\n\nUse only `wmill` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (`JobService`, `ScriptService`, ...) are importable from `windmill-client`. What is not available is a name you guessed at: `getBaseUrl` and `getWorkspaceToken` are inventions, not API.\n\n### Path runnables (script / flow / hubscript / agent)\n\nWhen `type` is `script`, `flow`, `hubscript` or `agent`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface; an agent's is `user_message` and `user_attachments`, plus `previous_messages` when its memory is off.\n\nBefore writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at `hub/<version>/<app>/<name>`) for a third-party service, and reference it instead of copying its logic.\n\n### Draft code vs deployed code\n\nThis decides whether an app works before anything is deployed:\n\n- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.\n- **Path runnables (`script` / `flow` / `hubscript` / `agent`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.\n\nSo an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.\n\nThat makes the fix a one-item deploy, not a release. Offer to deploy exactly the referenced flow or script and leave the app a draft the user keeps testing in the preview — do not push the whole change set through the review-and-deploy page, and do not ask the user to deploy the app, unless they said they want to ship it.\n\nDo NOT quietly reimplement the flow inside an inline runnable to dodge the deployment: that leaves the user with two copies of the same logic and an app that ignores the flow they asked for. Inline the logic only when the user actually wants it inline.\n\nPrefer a **path runnable of type `flow`** over an inline runnable that calls `wmill.runFlowAsync`. The path runnable gives the frontend the flow's real input schema and works with `backend` / `backendAsync` / `waitJob` like any other runnable; a hand-written wrapper gives up all of that.\n\n### Static inputs\n\n`staticInputs` is an optional `Record<string, any>` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller.\n\n## Who can open a deployed app\n\nA draft app is reachable by nobody; deploying is what exposes it, and its backend runnables with it. Otherwise only users with access to the app can open it, unless the app is opened to:\n\n- **anonymous** users: anyone with the URL, without logging in;\n- **guests**: anyone the instance's identity provider authenticates, whether or not they belong to the workspace.\n\nEither one lets those people run the app's backend runnables, so never open an app up unless the user asks.\nWhen a deploy widens who may open the app, tell the user in plain words.\n\n## Sandbox isolation and frontend API access\n\nNew apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them.\n\nInside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.\nYou cannot turn isolation off; the user does it in the app's deploy settings.\n\n## Reading data as the viewer\n\nA backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features.\nSet it with `viewerDataAccess: true` on the backend runnable.\n\n## Data Tables\n\nData tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.\n\n### Critical rules\n\n1. **Check what exists first**: look up the workspace's data tables and their tables before designing storage, and reuse a suitable table rather than creating another. Never assume a `main` data table exists.\n2. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Queries against unlisted tables fail at runtime, so register a new table there before using it.\n3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.\n4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.\n5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.\n6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see \"Reading data as the viewer\"). Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no `data.roles` entry. Data table roles are an Enterprise Edition feature, self-hosted only.\n\n### Querying in TypeScript (Bun/Deno)\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable(); // Or: wmill.datatable('other_datatable')\n\n // Parameterized queries (safe from SQL injection)\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n const users = await sql`SELECT * FROM users WHERE active = ${true}`.fetch();\n\n // Insert/Update\n await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`.execute();\n await sql`UPDATE users SET name = ${newName} WHERE id = ${user_id}`.execute();\n\n return user;\n}\n```\n\n### Querying in Python\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable() # Or: wmill.datatable('other_datatable')\n\n # Use $1, $2, etc. for parameters\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()\n\n # Insert/Update\n db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email).execute()\n db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id).execute()\n\n return user\n```\n\n## Best Practices\n\n1. **Check existing tables** before creating new ones — reuse beats schema growth.\n2. **Use parameterized queries** — never concatenate user input into SQL.\n3. **Terminate every datatable statement** — the tagged template and `db.query(...)` only build a statement. It runs when you call `fetch` / `fetchOne` / `fetchOneScalar` / `execute` (`fetch` / `fetch_one` / `fetch_one_scalar` / `execute` in Python). An INSERT or UPDATE without one writes nothing and raises nothing. Awaiting the statement itself is a no-op — it is not a promise.\n4. **Keep runnables focused** — one function per runnable; small surface area.\n5. **Use descriptive keys** — `get_user`, not `a`.\n6. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.\n7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.\n8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable.\n9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.\n10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow or a saved agent** — never hand-roll a runnable that runs it and polls its stream; a saved agent is an `agent` runnable passed as `{ wmill, runnable }`.\n\n\n# Python SDK (wmill)\n\nImport: import wmill\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe functions below are the surface to prefer. For an endpoint none of them covers,\nwmill.Windmill().get(endpoint) and .post(endpoint) issue an authenticated request against this\ninstance. What does not exist is a function name you guessed at: if it is not listed below, do\nnot call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`os.environ.get(\"WM_END_USER_EMAIL\") or os.environ.get(\"WM_EMAIL\")`. WM_END_USER_EMAIL is the app\nviewer when the run was triggered from an app and empty otherwise (both variables are always\ndefined), WM_EMAIL is the user the job is permissioned as. WM_USERNAME is the matching username.\n\ndef worker_has_internal_server() -> bool\n\ndef get_mocked_api() -> Optional[dict]\n\n# Get the HTTP client instance.\n# \n# Returns:\n# Configured httpx.Client for API requests\ndef get_client() -> httpx.Client\n\n# Make an HTTP GET request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.get\n# \n# Returns:\n# HTTP response object\ndef get(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Make an HTTP POST request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.post\n# \n# Returns:\n# HTTP response object\ndef post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Create a new authentication token.\n# \n# Args:\n# duration: Token validity duration (default: 1 day)\n# \n# Returns:\n# New authentication token string\ndef create_token(duration = dt.timedelta(days=1)) -> str\n\n# Create a script job by path and return its job id.\n# \n# ``retry`` re-runs the job when it fails, with the policy a schedule takes,\n# e.g. ``{\"constant\": {\"attempts\": 3, \"seconds\": 10}}``. The returned id is\n# the first attempt's; each retry is a job of its own, recorded as a child of it.\ndef run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str\n\n# Create a script job by hash and return its job id. See ``run_script_by_path_async`` for ``retry``.\ndef run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str\n\n# Create a flow job and return its job id.\ndef run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str\n\n# Run script by path synchronously and return its result.\ndef 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\n\n# Run script by hash synchronously and return its result.\ndef 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\n\n# Run a script on the current worker without creating a job.\n# \n# On agent workers (no internal server), falls back to running a normal\n# preview job and waiting for the result.\ndef run_inline_script_preview(content: str, language: str, args: dict = None) -> Any\n\n# Wait for a job to complete and return its result.\n# \n# Args:\n# job_id: ID of the job to wait for\n# timeout: Maximum time to wait (seconds or timedelta)\n# verbose: Enable verbose logging\n# cleanup: Register cleanup handler to cancel job on exit\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result when completed\n# \n# Raises:\n# TimeoutError: If timeout is reached\n# Exception: If job fails\ndef wait_job(job_id, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False)\n\n# Cancel a specific job by ID.\n# \n# Args:\n# job_id: UUID of the job to cancel\n# reason: Optional reason for cancellation\n# \n# Returns:\n# Response message from the cancel endpoint\ndef cancel_job(job_id: str, reason: str = None) -> str\n\n# Cancel currently running executions of the same script.\ndef cancel_running() -> dict\n\n# Get job details by ID.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job details dictionary\ndef get_job(job_id: str) -> dict\n\n# Get the root job ID for a flow hierarchy.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Root job ID\ndef get_root_job_id(job_id: str | None = None) -> dict\n\n# Get an OIDC JWT token for authentication to external services.\n# \n# Args:\n# audience: Token audience (e.g., \"vault\", \"aws\")\n# expires_in: Optional expiration time in seconds\n# \n# Returns:\n# JWT token string\ndef get_id_token(audience: str, expires_in: int | None = None) -> str\n\n# Get the status of a job.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job status: \"RUNNING\", \"WAITING\", or \"COMPLETED\"\ndef get_job_status(job_id: str) -> JobStatus\n\n# Get the result of a completed job.\n# \n# Args:\n# job_id: UUID of the completed job\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result\ndef get_result(job_id: str, assert_result_is_not_none: bool = True) -> Any\n\n# Get a variable value by path.\n# \n# Args:\n# path: Variable path in Windmill\n# \n# Returns:\n# Variable value as string\ndef get_variable(path: str) -> str\n\n# Set a variable value by path, creating it if it doesn't exist.\n# \n# Args:\n# path: Variable path in Windmill\n# value: Variable value to set\n# is_secret: Whether the variable should be secret (default: False)\ndef set_variable(path: str, value: str, is_secret: bool = False) -> None\n\n# Get a resource value by path.\n# \n# Args:\n# path: Resource path in Windmill\n# none_if_undefined: Return None instead of raising if not found\n# interpolated: if variables and resources are fully unrolled\n# \n# Returns:\n# Resource value dictionary or None\ndef get_resource(path: str, none_if_undefined: bool = False, interpolated: bool = True) -> dict | None\n\n# Set a resource value by path, creating it if it doesn't exist.\n# \n# Args:\n# value: Resource value to set\n# path: Resource path in Windmill\n# resource_type: Resource type for creation\ndef set_resource(value: Any, path: str, resource_type: str)\n\n# List resources from Windmill workspace.\n# \n# Args:\n# resource_type: Optional resource type to filter by (e.g., \"postgresql\", \"mysql\", \"s3\")\n# page: Optional page number for pagination\n# per_page: Optional number of results per page\n# \n# Returns:\n# List of resource dictionaries\ndef list_resources(resource_type: str = None, page: int = None, per_page: int = None) -> list[dict]\n\n# Set the workflow state.\n# \n# Args:\n# value: State value to set\n# path: Optional state resource path override.\ndef set_state(value: Any, path: str | None = None) -> None\n\n# Get the workflow state.\n# \n# Args:\n# path: Optional state resource path override.\n# \n# Returns:\n# State value or None if not set\ndef get_state(path: str | None = None) -> Any\n\n# Set job progress percentage (0-99).\n# \n# Args:\n# value: Progress percentage\n# job_id: Job ID (defaults to current WM_JOB_ID)\ndef set_progress(value: int, job_id: Optional[str] = None)\n\n# Get job progress percentage.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Progress value (0-100) or None if not set\ndef get_progress(job_id: Optional[str] = None) -> Any\n\n# Whether the worker running the job has received its shutdown signal.\n# \n# A draining worker waits for its current job to finish before exiting. A long-running\n# script can poll this and return early so the worker can be replaced.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# True if the worker running the job is draining\ndef worker_is_draining(job_id: Optional[str] = None) -> bool\n\n# Set the user state of a flow at a given key\ndef set_flow_user_state(key: str, value: Any) -> None\n\n# Get the user state of a flow at a given key\ndef get_flow_user_state(key: str) -> Any\n\n# Get the Windmill server version.\n# \n# Returns:\n# Version string\ndef version()\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the\n# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with\n# the storage's own credentials instead.\ndef get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the\n# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with\n# the storage's own credentials instead.\ndef get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: pass the storage's own bucket as ``Bucket``. The proxy\n# serves object reads, writes, deletes, listing and multipart uploads of that bucket only;\n# presigned URLs, other buckets and bucket-level APIs need the storage's own credentials,\n# which passing the resource path returns.\ndef get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Load a file from the workspace s3 bucket and returns its content as bytes.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# my_obj_content = client.load_s3_file(s3_obj)\n# file_content = my_obj_content.decode(\"utf-8\")\n# '''\ndef load_s3_file(s3object: S3Object | str, s3_resource_path: str | None) -> bytes\n\n# Load a file from the workspace s3 bucket and returns the bytes stream.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# with wmill.load_s3_file_reader(s3object, s3_resource_path) as file_reader:\n# print(file_reader.read())\n# '''\ndef load_s3_file_reader(s3object: S3Object | str, s3_resource_path: str | None) -> BufferedReader\n\n# Write a file to the workspace S3 bucket\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# \n# # for an in memory bytes array:\n# file_content = b'Hello Windmill!'\n# client.write_s3_file(s3_obj, file_content)\n# \n# # for a file:\n# with open(\"my_file.txt\", \"rb\") as my_file:\n# client.write_s3_file(s3_obj, my_file)\n# '''\ndef 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\n\n# Permanently delete a file from the workspace S3 bucket.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# client.delete_s3_object(s3_obj)\n# '''\ndef delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = None) -> None\n\n# Sign S3 objects for use by anonymous users in public apps.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# List of signed S3 objects\ndef sign_s3_objects(s3_objects: list[S3Object | str], expiry_secs: int | None = None) -> list[S3Object]\n\n# Sign a single S3 object for use by anonymous users in public apps.\n# \n# Args:\n# s3_object: S3 object to sign\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# Signed S3 object\ndef sign_s3_object(s3_object: S3Object | str, expiry_secs: int | None = None) -> S3Object\n\n# Generate presigned public URLs for an array of S3 objects.\n# If an S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# base_url: Optional base URL for the presigned URLs (defaults to WM_BASE_URL)\n# expiry_secs: How long the signatures stay valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# List of signed public URLs\n# \n# Example:\n# >>> s3_objs = [S3Object(s3=\"/path/to/file1.txt\"), S3Object(s3=\"/path/to/file2.txt\")]\n# >>> urls = client.get_presigned_s3_public_urls(s3_objs)\ndef get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None, expiry_secs: int | None = None) -> list[str]\n\n# Generate a presigned public URL for an S3 object.\n# If the S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_object: S3 object to sign\n# base_url: Optional base URL for the presigned URL (defaults to WM_BASE_URL)\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# Signed public URL\n# \n# Example:\n# >>> s3_obj = S3Object(s3=\"/path/to/file.txt\")\n# >>> url = client.get_presigned_s3_public_url(s3_obj)\ndef get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None, expiry_secs: int | None = None) -> str\n\n# Get the current user information.\n# \n# Returns:\n# User details dictionary\ndef whoami() -> dict\n\n# Get the current user information (alias for whoami).\n# \n# Returns:\n# User details dictionary\ndef user() -> dict\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef state_path() -> str\n\n# Get the workflow state.\n# \n# Returns:\n# State value or None if not set\ndef state() -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state_pickle(value: Any, path: str = 'state.pickle') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state_pickle(path: str = 'state.pickle') -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state(value: Any, path: str = 'state.json') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state(path: str = 'state.json') -> None\n\n# Get URLs needed for resuming a flow after suspension.\n# \n# Args:\n# approver: Optional approver name\n# flow_level: If True, generate resume URLs for the parent flow instead of the\n# specific step. This allows pre-approvals that can be consumed by any later\n# suspend step in the same flow.\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_resume_urls(approver: str = None, flow_level: bool = None) -> dict\n\n# Get the resume URLs bound to one ``wait_for_approval`` step of this workflow.\n# \n# Args:\n# step_key: Checkpoint key of the approval step, as passed to\n# ``wait_for_approval(key=...)``\n# approver: Optional approver name\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict\n\n# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n# \n# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the \"Advanced -> Suspend -> Form\" functionality.\n# Learn more at: https://www.windmill.dev/docs/flows/flow_approval#form\n# \n# :param slack_resource_path: The path to the Slack resource in Windmill.\n# :type slack_resource_path: str\n# :param channel_id: The Slack channel ID where the approval request will be sent.\n# :type channel_id: str\n# :param message: Optional custom message to include in the Slack approval request.\n# :type message: str, optional\n# :param approver: Optional user ID or name of the approver for the request.\n# :type approver: str, optional\n# :param default_args_json: Optional dictionary defining or overriding the default arguments for form fields.\n# :type default_args_json: dict, optional\n# :param dynamic_enums_json: Optional dictionary overriding the enum default values of enum form fields.\n# :type dynamic_enums_json: dict, optional\n# \n# :raises Exception: If the function is not called within a flow or flow preview.\n# :raises Exception: If the required flow job or flow step environment variables are not set.\n# \n# :return: None\n# \n# **Usage Example:**\n# >>> client.request_interactive_slack_approval(\n# ... slack_resource_path=\"/u/alex/my_slack_resource\",\n# ... channel_id=\"admins-slack-channel\",\n# ... message=\"Please approve this request\",\n# ... approver=\"approver123\",\n# ... default_args_json={\"key1\": \"value1\", \"key2\": 42},\n# ... dynamic_enums_json={\"foo\": [\"choice1\", \"choice2\"], \"bar\": [\"optionA\", \"optionB\"]},\n# ... )\n# \n# **Notes:**\n# - This function must be executed within a Windmill flow or flow preview.\n# - The function checks for required environment variables (`WM_FLOW_JOB_ID`, `WM_FLOW_STEP_ID`) to ensure it is run in the appropriate context.\ndef 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\n\n# Send a message to a Microsoft Teams conversation with conversation_id, where success is used to style the message\ndef send_teams_message(conversation_id: str, text: str, success: bool = True, card_block: dict = None)\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# Only meaningful on a data table under roles, and only for a role you are a\n# tenant of.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None)\n\n# Get a DuckLake client for DuckDB queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DucklakeClient instance\ndef ducklake(name: str = 'main')\n\ndef init_global_client(f)\n\ndef deprecate(in_favor_of: str)\n\n# Get the current workspace ID.\n# \n# Returns:\n# Workspace ID string\ndef get_workspace() -> str\n\ndef get_version() -> str\n\n# Create a script job and return its job ID.\n# \n# Args:\n# hash_or_path: Script hash or path (determined by presence of '/')\n# args: Script arguments\n# scheduled_in_secs: Delay before execution in seconds\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Job ID string\ndef run_script_async(hash_or_path: str, args: Dict[str, Any] = None, scheduled_in_secs: int = None, tag: str = None) -> str\n\n# Run a script synchronously by hash and return its result.\n# \n# Args:\n# hash: Script hash\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Script result\ndef 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\n\n# Run a script synchronously by path and return its result.\n# \n# Args:\n# path: Script path\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Script result\ndef 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\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef get_state_path() -> str\n\n# Parse resource syntax from string.\ndef parse_resource_syntax(s: str) -> Optional[str]\n\n# Parse S3 object from a `s3://<storage>/<key>` URI string (`s3:///<key>`\n# for the default storage) or S3Object format. Any other string raises\n# rather than falling back to an auto-generated key: an auto key is\n# requested by omitting the object, and a fallback would silently misplace\n# the upload on any typo.\ndef parse_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Parse variable syntax from string.\ndef parse_variable_syntax(s: str) -> Optional[str]\n\n# Append a text to the result stream.\n# \n# Args:\n# text: text to append to the result stream\ndef append_to_result_stream(text: str) -> None\n\n# Stream to the result stream.\n# \n# Args:\n# stream: stream to stream to the result stream\ndef stream_result(stream) -> None\n\n# Execute a SQL query against the DataTable.\n# \n# Args:\n# sql: SQL query string with $1, $2, etc. placeholders\n# *args: Positional arguments to bind to query placeholders\n# \n# Returns:\n# SqlQuery instance for fetching results\ndef query(sql: str, *args) -> SqlQuery\n\n# Idempotently materialize the rows of `select_sql` into ducklake\n# `table` for one `partition` (or the whole table when `partition` is\n# None). Client-side equivalent of the `// materialize` engine: with\n# `unique_key` it upserts within the slice (delete-by-key + insert);\n# without it, it replaces (whole table → CREATE OR REPLACE; partition →\n# delete the partition + insert). Re-running the same slice is safe — the\n# backfill / failure-recovery contract.\n# \n# The partition value is bound as a DuckDB arg (never string-interpolated)\n# so it cannot inject SQL. `select_sql` is trusted (your own query).\ndef upsert_partition(table: str, select_sql: str, partition: str = None, unique_key: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# INSERT-only materialization (no dedup / no replace) for an immutable\n# event-log table — for one `partition`, or the whole table when\n# `partition` is None. NOTE: unlike `upsert_partition`, re-running the same\n# slice duplicates rows — use only for append-only sources.\ndef append_partition(table: str, select_sql: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# Read a materialized ducklake table, optionally a single partition.\ndef read(table: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# Execute query and fetch results.\n# \n# Args:\n# result_collection: Optional result collection mode\n# \n# Returns:\n# Query results\ndef fetch(result_collection: str | None = None)\n\n# Execute query and fetch first row of results.\n# \n# Returns:\n# First row of query results\ndef fetch_one()\n\n# Execute query and fetch first row of results. Return result as a scalar value.\n# \n# Returns:\n# First row of query result as a scalar value\ndef fetch_one_scalar()\n\n# Execute query and don't return any results.\n# \ndef execute()\n\n# DuckDB executor requires explicit argument types at declaration\n# These types exist in both DuckDB and Postgres\n# Check that the types exist if you plan to extend this function for other SQL engines.\ndef infer_sql_type(value) -> str\n\ndef parse_sql_client_name(name: str) -> tuple[str, Optional[str]]\n\n# Decorator that marks a function as a workflow task.\n# \n# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2\n# (async, checkpoint/replay) modes:\n# \n# - **v2 (inside @workflow)**: dispatches as a checkpoint step.\n# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.\n# - **Standalone, or called from inside another v2 task**: executes the\n# function body directly. A task runs as a single job, so the tasks it\n# calls run in that same job, without their own options.\n# \n# A task runs as its own job, so its result is always encoded as JSON and\n# decoded back before the caller sees it: a ``datetime`` comes back as a\n# string, a tuple as a list.\n# \n# ``retry`` re-dispatches the task after a failure, inside ``@workflow`` only.\n# Every attempt is a step of its own (``call_api``, ``call_api#2``, ...) and\n# the wait between two of them is a durable sleep, so a retrying task holds no\n# worker while it backs off. Keys: ``attempts`` (retries after the first\n# failure, a whole number from 0 to 100), ``delay`` (seconds before the first\n# retry, sub-second delays dropped), ``multiplier`` (applied to the delay\n# after each attempt, 1 keeps it constant), ``max_delay`` (ceiling in\n# seconds). ``attempts`` is required, and an out-of-range or unknown key is\n# rejected where the policy is written.\n# \n# A workflow sleeps once per round, so tasks backing off in the same fan-out\n# wait one after another rather than together: the delay before a fan-out\n# retries is the sum of every backoff pending in it, not the longest one, and\n# it grows with both the width of the fan-out and ``attempts``. Retries with\n# no ``delay`` all go out in a single round.\n# \n# ``cache_ttl`` serves a previous result of the task for that many seconds\n# instead of running it again. A task is keyed on its step key (its name and\n# call order) and the workflow's input, not on the arguments it is called\n# with, so cache one only when whether it runs, and what it receives, follow\n# from the workflow's input alone. A ``task_script`` target is keyed on the\n# arguments it is called with. It has no effect on a ``task_flow`` target,\n# which keeps its flow's own cache policy.\n# \n# Usage::\n# \n# @task\n# async def extract_data(url: str): ...\n# \n# @task(path=\"f/external_script\", timeout=600, tag=\"gpu\")\n# async def run_external(x: int): ...\n# \n# @task(retry={\"attempts\": 3, \"delay\": 30, \"multiplier\": 2})\n# async def call_api(payload: dict): ...\ndef 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)\n\n# Create a task that dispatches to a separate Windmill script.\n# \n# ``retry`` takes the same policy as :func:`task`.\n# \n# Usage::\n# \n# extract = task_script(\"f/data/extract\", timeout=600)\n# \n# @workflow\n# async def main():\n# data = await extract(url=\"https://...\")\ndef 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)\n\n# Create a task that dispatches to a separate Windmill flow.\n# \n# ``retry`` takes the same policy as :func:`task`.\n# \n# Usage::\n# \n# pipeline = task_flow(\"f/etl/pipeline\", priority=10)\n# \n# @workflow\n# async def main():\n# result = await pipeline(input=data)\ndef 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)\n\n# Decorator marking an async function as a workflow-as-code entry point.\n# \n# The function must be **deterministic**: given the same inputs it must call\n# tasks in the same order on every replay. Branching on task results is fine\n# (results are replayed from checkpoint), but branching on external state\n# (current time, random values, external API calls) must use ``step()`` to\n# checkpoint the value so replays see the same result.\ndef workflow(func)\n\n# Execute ``fn`` inline and checkpoint the result.\n# \n# On replay the cached value is returned without re-executing ``fn``.\n# Use for lightweight deterministic operations (timestamps, random IDs,\n# config reads) that should not incur the overhead of a child job.\n# \n# ``fn``'s result is encoded as JSON and decoded back before it is returned,\n# so the round that runs the body sees the same types every replay sees:\n# a ``datetime`` comes back as a string, a tuple as a list.\n# \n# Called from inside a task body, ``fn`` runs in place and nothing is\n# checkpointed: a task is a single job and is never replayed.\nasync def step(name: str, fn)\n\n# Server-side sleep — suspend the workflow for the given duration without holding a worker.\n# \n# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.\n# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.\nasync def sleep(seconds: int)\n\n# Suspend the workflow and wait for an external approval.\n# \n# Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs\n# that resume exactly this approval — route them through your own channel.\n# Without a key the steps are named ``approval``, ``approval_2``, ...\n# \n# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.\n# \n# Args:\n# timeout: Approval timeout in seconds (default 1800).\n# form: Optional form schema for the approval page.\n# self_approval: Whether the user who triggered the flow can approve it (default True).\n# key: Optional checkpoint key naming this approval step.\n# skin: ``\"minimal\"`` shows approvers only the request (form and approve/reject)\n# instead of the detailed page with the workflow's details.\n# description: Shown to approvers above the form: a string, or a rich value such as\n# ``{\"markdown\": \"...\"}``.\n# \n# Example::\n# \n# urls = await step(\"urls\", lambda: get_approval_urls(\"manager\"))\n# await step(\"notify\", lambda: send_email(urls[\"resume\"], urls[\"cancel\"]))\n# result = await wait_for_approval(key=\"manager\", timeout=3600)\nasync 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\n\n# Process items in parallel with optional concurrency control.\n# \n# Each item is processed by calling ``fn(item)``, which should be a @task.\n# Items are dispatched in batches of ``concurrency`` (default: all at once).\n# \n# Example::\n# \n# @task\n# async def process(item: str):\n# ...\n# \n# results = await parallel(items, process, concurrency=5)\nasync def parallel(items, fn, *, concurrency: Optional[int] = None)\n\n# Commit Kafka offsets for a trigger with auto_commit disabled.\n# \n# Args:\n# trigger_path: Path to the Kafka trigger (from event['wm_trigger']['trigger_path'])\n# topic: Kafka topic name (from event['topic'])\n# partition: Partition number (from event['partition'])\n# offset: Message offset to commit (from event['offset'])\ndef commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None\n\n", @@ -261,6 +261,6 @@ "datatable.python3": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n # role: Data table role to connect as, or None for the data table's default\n def __init__(client: Windmill, name: str, role: Optional[str] = None)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n", "datatable.go": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement<T> = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record<string, any>;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch<ResultCollectionT extends ResultCollection = \"last_statement_all_rows\">(\n params?: FetchParams<ResultCollectionT | ResultCollection> // The union is for auto-completion\n ): Promise<SqlResult<T, ResultCollectionT>>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<SqlResult<T, \"last_statement_first_row\">>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise<SqlResult<T, \"last_statement_first_row_scalar\">>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<void>;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n <T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;\n query<T = any>(sql: string, ...params: any[]): SqlStatement<T>;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@param opts.role - Connect as this data table role instead of the data table's default one.\n Only meaningful on a data table under roles, and only for a role you are a tenant of.\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n@example\n// Read through a restricted role\nlet sql = wmill.datatable(\"main\", { role: \"analytics\" })\n```typescript\nfunction datatable(name: string = \"main\",\n opts?: DatatableOptions): DatatableSqlTemplateFunction\n```\n\n\n## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n # role: Data table role to connect as, or None for the data table's default\n def __init__(client: Windmill, name: str, role: Optional[str] = None)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n", "pipeline": "# Data pipeline authoring\n\nA **data pipeline** is NOT a flow. A flow is one runnable that orchestrates steps internally. A data pipeline is a set of **independent scripts**, each deployed on its own, that form a DAG by reading and writing shared **storage assets** (DuckLake tables, data tables, S3 objects, volumes, resources) and by declaring execution **triggers**. The pipeline is visualized and edited at `/pipeline/<folder>`; every node is a normal workspace script that happens to carry pipeline annotations. When the user asks for a \"data pipeline\" (or to \"ingest / transform / materialize\" data across steps), build pipeline-annotated scripts — do NOT build a flow.\n\n## Default to DuckDB + DuckLake\n\nA pipeline node that produces a table should almost always be a **`duckdb`** node that materializes its output into a **DuckLake** table with `-- materialize ducklake://<name>/<table>` (in a DuckDB node the annotation uses SQL `--` comment syntax; write the body as a bare `SELECT` and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else:\n\n- `postgresql` / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live).\n- `bun` / `python3` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see \"Ingesting from Python / TypeScript\" below — rather than inventing a parallel store.\n\nDo not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal.\n\n## Ingesting from Python / TypeScript\n\nA node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with `wmill.ducklake()`, or into a data table with `wmill.datatable()`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second \"load the raw file\" node. One ingestion node, one table.\n\nPass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema:\n\n```python\n# pipeline\n# on schedule\nimport wmill\n\ndef main():\n rows = fetch_charges() # list[dict]\n lake = wmill.ducklake()\n lake.query(\"\"\"\n CREATE OR REPLACE TABLE stripe_charges AS\n SELECT unnest(from_json($rows, '[{\"id\":\"VARCHAR\",\"amount\":\"BIGINT\",\"created\":\"BIGINT\"}]'), recursive := true)\n \"\"\", rows=rows).fetch()\n```\n\n```ts\n// pipeline\n// on schedule\nimport * as wmill from \"windmill-client\"\n\nexport async function main() {\n const rows = await fetchCharges()\n const lake = wmill.ducklake()\n await lake`CREATE OR REPLACE TABLE stripe_charges AS\n SELECT unnest(from_json(${rows}, '[{\"id\":\"VARCHAR\",\"amount\":\"BIGINT\",\"created\":\"BIGINT\"}]'), recursive := true)`.fetch()\n}\n```\n\n- Use `INSERT INTO <table> SELECT ...` instead of `CREATE OR REPLACE` to append (an incremental pull), with a one-time `CREATE TABLE IF NOT EXISTS` first.\n- Assign the client to a variable (`lake = wmill.ducklake()`) and keep the SQL a **string literal** (Python) or a template without `sql.raw` (TS): that is what lets the graph record the node's write to `ducklake://main/<table>`. A chained `wmill.ducklake().query(...)` or SQL built in a variable records no output, so the downstream edge never forms.\n- `wmill.ducklake(\"<name>\")` targets a non-default catalog; `wmill.datatable(\"<name>\")` writes a Postgres data table the same way (Python `db.query(sql, *args)` with `$1` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above).\n- The downstream node triggers on the table itself: `-- on ducklake://main/stripe_charges`.\n\n## Storage prerequisites\n\nA DuckLake pipeline only runs once the workspace has **object storage** (S3 / Azure Blob / GCS) **and a DuckLake catalog** configured — DuckLake tables and `s3://` assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build.\nDrafting the annotated scripts does not require storage, but the pipeline can't ingest, materialize, or read its assets until it exists. So if there is none (or the user hits \"storage not configured\" errors), say so and give the right next step **by role**:\n\n- a workspace **admin** sets it up in Workspace settings → Object Storage (add an S3/Azure/GCS storage), then adds a DuckLake catalog on top of it;\n- anyone **without admin rights** should ask a workspace admin to configure object storage + a DuckLake catalog.\n\nNever hand back a DuckLake pipeline that cannot run without flagging the missing storage and pointing to who sets it up.\n\n## What makes a script a pipeline node\n\nA script joins the pipeline when its source begins with the `pipeline` annotation as a top-of-file comment, **written in the script's own comment syntax** — `//` for TS/JS (bun), `--` for SQL (DuckDB/Postgres), `#` for Python/Bash. So it's `-- pipeline` in a DuckDB node, `# pipeline` in a Python node, `// pipeline` in a bun node. Every annotation below uses that same prefix (the `//` shown is the TS form). All other wiring is expressed as annotation comments near the top of the file:\n\n- `// on <ref>` — declares an execution-DAG **input** (what triggers/feeds this node). `<ref>` is either:\n - an **asset URI** (the node runs when that asset is produced upstream): `ducklake://main/orders`, `datatable://main/users`, `$res:f/folder/my_resource`, `volume://name/path`, or an S3 object (see the S3 storage-form rule below).\n - a **native trigger kind**: `schedule`, `webhook`, `email`, `kafka`, `mqtt`, `amqp`, `nats`, `postgres`, `sqs`, `gcp`, or `data_upload` (a user-uploaded S3 file). For these the actual trigger row (cron, topic, …) is created separately; the annotation only declares the binding. **`data_upload` is special**: there is no trigger row — the node instead declares an **`S3Object` input parameter** fed by the auto-generated upload picker; it never hard-codes a key. Any language can be the `data_upload` node:\n - Python (has `import wmill`): `def main(file: wmill.S3Object):` then `wmill.load_s3_file(file)`; TS (has `import * as wmill from \"windmill-client\"`): `export async function main(file: wmill.S3Object)`. Qualify the type as `wmill.S3Object` (or add `from wmill import S3Object` / `import { S3Object } from \"windmill-client\"`) — a bare `S3Object` is undefined.\n - **DuckDB** takes the s3object arg via a `-- $<name> (s3object)` declaration and reads it directly, so a single DuckDB node can ingest **and** materialize:\n ```\n -- pipeline\n -- on data_upload\n -- materialize ducklake://main/raw_uploads\n -- $file (s3object)\n SELECT * FROM read_csv($file)\n ```\n- **Outputs** are inferred from what the body writes — a `CREATE TABLE`, a `wmill.writeS3File(...)`, a DuckLake/datatable write. To declare a managed output explicitly, use `// materialize <asset-uri>`.\n- Optional badges: `// partitioned <daily|hourly|weekly|monthly|dynamic>`, `// freshness <duration>` (e.g. `1h`), `// tag <worker-tag>`, `// retry <count> [delay]`, `// data_test <kind> ...` (managed DuckLake targets only — deploy rejects it beside a `dbt://` one), `// measure <name> = <agg> [where <pred>]` and `// dimension <name> = <expr>` (see \"Declared metrics\" below).\n\n## S3 object wiring (storage form matters)\n\nAn `s3://` URI's first slashes select the **storage**, not part of the key — get this wrong and the producer/consumer edge silently won't connect:\n\n- `s3:///<key>` (**triple** slash, empty first segment) = the **default** workspace storage. A downstream node reading or triggering on that object uses `s3:///<key>` — e.g. DuckDB `-- on s3:///orders/2024.parquet` and `read_parquet('s3:///orders/2024.parquet')`.\n- `s3://<storage>/<key>` (**double** slash, non-empty first segment) = a **named secondary** storage called `<storage>` — so `s3://ingest/x` means storage `ingest`, key `x`, NOT key `ingest/x`. Only use this when the object genuinely lives in a configured secondary storage; never invent a bucket/storage name for a default-storage object (it breaks the edge).\n\nTo make the producer side visible to lineage, a Python/TS node MUST pass the **`S3Object` form**, not a bare key string: Python `wmill.write_s3_file(wmill.S3Object(s3=\"<key>\"), data)` (or the import-free dict `{\"s3\": \"<key>\"}`), TS `wmill.writeS3File({ s3: \"<key>\" }, data)`. That records the default-storage asset `/<key>`, which a downstream `s3:///<key>` reader connects to (same key both sides). A bare `write_s3_file(\"<key>\", ...)` records **no** asset and produces **no** edge. Add `storage=\"<name>\"` only for a named secondary storage.\n\nThe key must be a **string literal** — the graph parser is static and cannot follow a variable, f-string, or computed path, so `write_s3_file(wmill.S3Object(s3=key_var), ...)` records no edge. Inline the literal (`s3=\"events/user_events.parquet\"`) on both the writing and reading node. The same rule applies to every asset URI in an annotation or SDK call (`ducklake://`, `datatable://`, `s3://`): write them literally, not via a variable.\n\n## Materialize (the managed output)\n\n> **A MANAGED `// materialize` is DuckDB-only**, and its target must be a DuckLake table (`ducklake://<name>/<table>`). Deploy **rejects** a `ducklake://` `// materialize` on any other language (`python3`, `bun`, `postgresql`). For a non-DuckDB node writing the lake, do **not** use `// materialize` — write the table itself with `wmill.ducklake()` (see \"Ingesting from Python / TypeScript\") and let the output be inferred. Use `duckdb` when a node should materialize a DuckLake table.\n>\n> The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a **warehouse relation**: `// materialize manual dbt://<warehouse>/<schema>/<name>`, where `<warehouse>` is a warehouse the workspace configures under Settings → dbt. `manual` is the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresql `CREATE TABLE` / `INSERT`, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as a `source`: the declared relation and the dbt model land on ONE graph node, and a downstream `// on dbt://<warehouse>/<schema>/<name>` fires when the ingestion node completes.\n\nA managed `// materialize ducklake://<name>/<table>` tells the runtime to write the node's output table **for you**: write the body as a single `SELECT` and the runtime wraps it in the create/replace — do **not** also write your own `CREATE TABLE` / `INSERT`. (The opposite holds for the `dbt://` target above: there the node writes its own DDL and the strategies below do not apply.) Write strategy:\n\n- no option → **replace** the whole table each run (full refresh; the only mode whose output columns may change);\n- `// materialize <uri> append` → INSERT-append rows (incremental);\n- `// materialize <uri> key=<col>` → merge/upsert on `<col>`.\n\n`// materialize manual <uri>` opts **out** of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage.\n\n`materialize` pairs with partitioning for incremental pipelines: a `// partitioned <daily|hourly|weekly|monthly|dynamic>` node runs **once per partition** (append/merge into a fixed-schema table). The `{partition}` token, usable in any asset URI **and** in the body SQL, is replaced at run time by the current partition's **identity string**:\n\n- To filter the source to the active slice on a time grain, use the runtime-injected macro: `WHERE wm_partition(<ts_col>) = {partition}`. `wm_partition(ts)` buckets a timestamp in exactly the identity format the runtime uses for daily/hourly/weekly/monthly, so it always matches; never hand-write a `strftime` format.\n- Do NOT write `= TIMESTAMP {partition}`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at run time.\n- For `dynamic` partitioning the identity is the caller-supplied key (not a timestamp, no macro), so filter on it directly: `WHERE <your_key_col> = {partition}`.\n\n`materialize` is an output **declaration** on a node — not a command. There is no \"materialize run\".\n\n## Declared metrics (`measure` / `dimension`)\n\nOn a node that materializes a DuckLake table, `// measure <name> = <aggregate> [where <predicate>]` names the canonical way to aggregate that table (e.g. `// measure revenue = sum(amount) where not is_refund`), and `// dimension <name> = <expr>` names a way to slice it (e.g. `// dimension region = region`, `// dimension month = date_trunc('month', ordered_at)`). They execute nothing: they are catalogued at deploy so the editor and other agents reuse the definition instead of re-deriving it and silently disagreeing.\n\n- Keep the predicate in the `where` clause rather than folding it into the aggregate: it is rendered as `<agg> FILTER (WHERE <pred>)`, which is what lets two measures with different predicates share one GROUP BY.\n- DuckLake-only, and only meaningful next to `// materialize`.\n- Declare one when a number carries a judgement call someone else would get wrong (refunds excluded, test rows dropped, which column is the amount); do NOT blanket every table with measures — an obvious `count(*)` earns nothing.\n- To use a metric another node declares, read that node and reuse its exact expression rather than guessing it.\n\n## How to build one\n\n1. Put every node in the **same folder**: `f/<folder>/<name>`. The folder is the pipeline.\n2. Write each node as its own script. Default to `duckdb` materializing into DuckLake (see \"Default to DuckDB + DuckLake\" above); pick `postgresql`, `bun`, or `python3` only when that section says the work calls for it.\n3. Start each body with `// pipeline`, then the `// on` input declarations, then the transform that writes the output.\n4. **Chain nodes by asset URI**: read an upstream node's output asset, then `// on <that-same-uri>` in the downstream node so the edge forms. Reuse exact asset paths from existing nodes rather than inventing parallel ones.\n5. Don't deploy nodes unless the user asks to. A pipeline only \"runs\" once its scripts are deployed and their triggers exist.\n\n## Example (DuckDB → DuckLake, scheduled ingest + downstream transform)\n\nNode `f/sales/orders_ingest` (runs on a schedule, materializes a DuckLake table):\n\n```sql\n-- pipeline\n-- on schedule\n-- materialize ducklake://main/orders\nSELECT * FROM read_csv('s3:///raw/orders/*.csv')\n```\n\nNode `f/sales/orders_daily` (runs when `orders` is produced, writes a rollup):\n\n```sql\n-- pipeline\n-- on ducklake://main/orders\n-- materialize ducklake://main/orders_daily\nSELECT date_trunc('day', ts) AS day, count(*) AS n\nFROM ducklake.main.orders GROUP BY 1\n```\n", - "workspace_settings": "# Workspace settings\n\nEverything an admin sets on the Workspace settings page is one document: a mapping of top-level\nkeys, the same layout as the `settings.yaml` file `wmill sync` keeps. Only a workspace admin can\nread or change it.\n\n## Applying a document\n\n- A partial document changes only the keys it states; every key it omits is left as it is.\n- Each key is replaced whole, so read its current value and send it back edited: a nested object\n given with one field replaces the stored object with that one field.\n- A key set to `null` clears that setting; an on/off switch cleared this way reads as `false`.\n- Each setting is written through the same path the settings page uses, with the same validation\n and audit log. The writes are not one transaction: when one fails the apply stops, and the\n error says what was already written.\n- A dry run reports the changes, as `before` / `after` per key, without writing anything.\n- `name` is never applied: rename a workspace on the settings page. `color` is applied only when\n set to a value, so `null` does not clear it.\n- A key the server does not know is ignored and named in the warnings: check them, since a typo\n otherwise reads as \"nothing to change\".\n- A setting this edition does not offer (Enterprise only) is refused, by the dry run too.\n\n## Secrets\n\n`slack_oauth_client_secret` is the one secret. A read shows `__SENSITIVE_AND_UNCHANGED__` in place\nof a set secret; sending that placeholder back keeps the stored value. A value starting with\n`$encrypted:` is decrypted with the workspace key, and one encrypted with another workspace's key\nis left unchanged with a warning; any other value is taken as plaintext. Never\nwrite a secret you were not given: keep the placeholder.\n\n## Keys\n\n| Key | Value |\n|---|---|\n| `auto_invite` | `{ enabled, mode: invite \\| add, operator, instance_groups?, instance_groups_roles? }`. The instance groups are managed only when `instance_groups` is stated; clearing a group removes the members it added. |\n| `error_handler` / `success_handler` | `{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }`, `path` being `script/<path>` or `flow/<path>`. |\n| `error_handler_fallback_to_instance_alerts` | Report failures to the instance critical alert channels when no error handler is set. |\n| `webhook` | URL POSTed a JSON event on every change to the workspace's items. |\n| `ai_config` | Providers, default and completion models, custom prompts, `copilot_disabled`. |\n| `large_file_storage` | Workspace object storage: `{ type, s3_resource_path \\| azure_blob_resource_path \\| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }`. |\n| `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |\n| `datatable` | `{ datatables: { <name>: { database: { resource_type, resource_path } } } }`, both required: `resource_type: postgresql` with `resource_path` a Postgres resource's path, or `instance`, or `external_instance` (Enterprise only), with `resource_path` the name of a database a superadmin set up. A fork's entry can instead be `{ reference: { workspace_id, datatable } }`, pointing at its parent's data table; keep it as is. `main` is the one scripts use by default. Role permissions are `datatable_permissions`. |\n| `datatable_permissions` | `{ <data table>: { default_role, roles: { <role>: [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/<user>`, `g/<group>`, `f/<folder>` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |\n| `protection_rules` | `{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...], fork_role_mapping?: { <role here>: <role in its forks> } } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |\n| `ducklake` | `{ ducklakes: { <name>: { catalog, storage, ... } } }`. |\n| `dbt_warehouses` | dbt warehouse connections. |\n| `default_app` | Path of the app shown as the workspace home. |\n| `default_scripts` | `{ order?, hidden?, default_script_content? }` for the script language picker. |\n| `deploy_ui` | `{ include_path?, include_type? }`: what the deploy-to-workspace UI offers. |\n| `operator_settings` | What operators see and do: page visibility flags (`runs`, `schedules`, `resources`, `variables`, `assets`, `audit_logs`, `groups`, `folders`, `workers`, `triggers`) and write rights (`manage_schedules`, `manage_triggers`, `builder_flows`, `builder_apps`). A write right the value omits keeps its stored value. |\n| `mute_critical_alerts` | Mute the workspace's critical alerts. |\n| `slack_team_id`, `slack_name` | Read only: set by connecting Slack on the settings page. |\n| `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. |\n| `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. |\n| `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. |\n| `guest_access_enabled`, `guest_jwt_public_key`, `guest_jwt_jwks_url` | Guest access to public apps and the key that signs guest JWTs. |\n| `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. |\n| `color` | Workspace color, `#rrggbb`. |\n" + "workspace_settings": "# Workspace settings\n\nEverything an admin sets on the Workspace settings page is one document: a mapping of top-level\nkeys, the same layout as the `settings.yaml` file `wmill sync` keeps. Only a workspace admin can\nread or change it.\n\n## Applying a document\n\n- A partial document changes only the keys it states; every key it omits is left as it is.\n- Each key is replaced whole, so read its current value and send it back edited: a nested object\n given with one field replaces the stored object with that one field.\n- A key set to `null` clears that setting; an on/off switch cleared this way reads as `false`.\n- Each setting is written through the same path the settings page uses, with the same validation\n and audit log. The writes are not one transaction: when one fails the apply stops, and the\n error says what was already written.\n- A dry run reports the changes, as `before` / `after` per key, without writing anything.\n- `name` is never applied: rename a workspace on the settings page. `color` is applied only when\n set to a value, so `null` does not clear it.\n- A key the server does not know is ignored and named in the warnings: check them, since a typo\n otherwise reads as \"nothing to change\".\n- A setting this edition does not offer (Enterprise only) is refused, by the dry run too.\n\n## Secrets\n\n`slack_oauth_client_secret` is the one secret. A read shows `__SENSITIVE_AND_UNCHANGED__` in place\nof a set secret; sending that placeholder back keeps the stored value. A value starting with\n`$encrypted:` is decrypted with the workspace key, and one encrypted with another workspace's key\nis left unchanged with a warning; any other value is taken as plaintext. Never\nwrite a secret you were not given: keep the placeholder.\n\n## Keys\n\n| Key | Value |\n|---|---|\n| `auto_invite` | `{ enabled, mode: invite \\| add, operator, instance_groups?, instance_groups_roles? }`. The instance groups are managed only when `instance_groups` is stated; clearing a group removes the members it added. |\n| `error_handler` / `success_handler` | `{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }`, `path` being `script/<path>` or `flow/<path>`. |\n| `error_handler_fallback_to_instance_alerts` | Report failures to the instance critical alert channels when no error handler is set. |\n| `webhook` | URL POSTed a JSON event on every change to the workspace's items. |\n| `ai_config` | Providers, default and completion models, custom prompts, `copilot_disabled`. |\n| `large_file_storage` | Workspace object storage: `{ type, s3_resource_path \\| azure_blob_resource_path \\| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }`. |\n| `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |\n| `datatable` | `{ datatables: { <name>: { database: { resource_type, resource_path } } } }`, both required: `resource_type: postgresql` with `resource_path` a Postgres resource's path, or `instance`, or `external_instance` (Enterprise only), with `resource_path` the name of a database a superadmin set up. A fork's entry can instead be `{ reference: { workspace_id, datatable } }`, pointing at its parent's data table; keep it as is. `main` is the one scripts use by default. Role permissions are `datatable_permissions`. |\n| `datatable_permissions` | `{ <data table>: { default_role, roles: { <role>: [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/<user>`, `g/<group>`, `f/<folder>` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |\n| `protection_rules` | `{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...], fork_role_mapping?: { <role here>: <role in its forks> } } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |\n| `ducklake` | `{ ducklakes: { <name>: { catalog, storage, ... } } }`. |\n| `dbt_warehouses` | dbt warehouse connections. |\n| `default_app` | Path of the app shown as the workspace home. |\n| `default_scripts` | `{ order?, hidden?, default_script_content? }` for the script language picker. |\n| `deploy_ui` | `{ include_path?, include_type? }`: what the deploy-to-workspace UI offers. |\n| `operator_settings` | What operators see and do: page visibility flags (`runs`, `schedules`, `resources`, `variables`, `assets`, `audit_logs`, `groups`, `folders`, `workers`, `triggers`) and write rights (`manage_schedules`, `manage_triggers`, `builder_flows`, `builder_apps`). A write right the value omits keeps its stored value. |\n| `mute_critical_alerts` | Mute the workspace's critical alerts. |\n| `slack_team_id`, `slack_name` | Read only: set by connecting Slack on the settings page. |\n| `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. |\n| `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. |\n| `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. |\n| `guest_jwt_public_key`, `guest_jwt_jwks_url` | The key that signs guest JWTs for apps set to Guests. |\n| `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. |\n| `color` | Workspace color, `#rrggbb`. |\n" } } diff --git a/backend/windmill-api/src/mcp/session/workspace.rs b/backend/windmill-api/src/mcp/session/workspace.rs index ef31947243..720dbbac76 100644 --- a/backend/windmill-api/src/mcp/session/workspace.rs +++ b/backend/windmill-api/src/mcp/session/workspace.rs @@ -800,20 +800,15 @@ pub(super) async fn describe_app_exposure(ctx: &Ctx<'_>, mode: Option<&Value>) - if mode.and_then(Value::as_str) != Some("guest") { return mode.cloned(); } - let usage = ctx.get(&ctx.ws("workspaces/guest_usage")).await; - let settings = ctx.get(&ctx.ws("workspaces/get_public_settings")).await; - // A switch that could not be read proves nothing either way: report the mode bare. - let (Ok(usage), Ok(settings)) = (usage, settings) else { + // A deployment that could not be read proves nothing either way: report the mode bare. + let Ok(usage) = ctx.get(&ctx.ws("workspaces/guest_usage")).await else { return mode.cloned(); }; - let live = truthy(usage.get("available")) - && truthy(usage.get("instance_enabled")) - && truthy(settings.get("guest_access_enabled")); - if live { + if truthy(usage.get("available")) { mode.cloned() } else { Some(json!( - "guest, but inert: the instance or workspace admits no guest, so this app admits members only" + "guest, but inert: this deployment admits no guest, so this app admits members only" )) } } diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index ecf77b6d5f..4b8d3481c3 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -443,9 +443,10 @@ async fn update_username_in_workpsace<'c>( .await?; sqlx::query!( - "UPDATE usr_to_group SET usr = $1 WHERE usr = $2", + "UPDATE usr_to_group SET usr = $1 WHERE usr = $2 AND workspace_id = $3", new_username, - old_username + old_username, + w_id ) .execute(&mut **tx) .await?; diff --git a/backend/windmill-api/src/workspace_settings_document.rs b/backend/windmill-api/src/workspace_settings_document.rs index cb0c2e67aa..5f0f062d10 100644 --- a/backend/windmill-api/src/workspace_settings_document.rs +++ b/backend/windmill-api/src/workspace_settings_document.rs @@ -81,7 +81,6 @@ struct SettingsRow { deploy_ui: Option<Value>, teams_command_script: Option<String>, public_app_execution_limit_per_minute: Option<i32>, - guest_access_enabled: Option<bool>, guest_jwt_public_key: Option<String>, guest_jwt_jwks_url: Option<String>, error_handler_fallback_to_instance_alerts: Option<bool>, @@ -272,16 +271,18 @@ async fn protection_rules_document(conn: &mut PgConnection, w_id: &str) -> Resul .await?; Ok(Value::Object( rows.into_iter() - .map(|(name, bits, bypass_groups, bypass_users, fork_role_mapping)| { - let rules = ProtectionRules::from_bits_truncate(bits); - let mut rule = json!({ - "rules": rule_kinds(rules), - "bypass_groups": bypass_groups, - "bypass_users": bypass_users, - }); - with_fork_role_mapping(&mut rule, fork_role_mapping); - (name, rule) - }) + .map( + |(name, bits, bypass_groups, bypass_users, fork_role_mapping)| { + let rules = ProtectionRules::from_bits_truncate(bits); + let mut rule = json!({ + "rules": rule_kinds(rules), + "bypass_groups": bypass_groups, + "bypass_users": bypass_users, + }); + with_fork_role_mapping(&mut rule, fork_role_mapping); + (name, rule) + }, + ) .collect(), )) } @@ -312,7 +313,7 @@ pub async fn load_settings_document( git_sync, default_app, default_scripts, workspace.name AS name, mute_critical_alerts, color, operator_settings, datatable, slack_team_id, slack_name, slack_command_script, slack_oauth_client_id, slack_oauth_client_secret, ducklake, dbt_warehouses, deploy_ui, - teams_command_script, public_app_execution_limit_per_minute, guest_access_enabled, + teams_command_script, public_app_execution_limit_per_minute, guest_jwt_public_key, guest_jwt_jwks_url, error_handler_fallback_to_instance_alerts, add_admins_and_developers_to_forks FROM workspace_settings @@ -360,7 +361,7 @@ pub async fn load_settings_document( // In the order `wmill sync pull` has always written settings.yaml. A key that predates the // document and is not marked `always` is written only when set, as it always was; a key // added since is always written, so a pulled file states it and from then on manages it. - let entries: [(&str, Option<Value>, bool); 32] = [ + let entries: [(&str, Option<Value>, bool); 31] = [ ("auto_invite", row.auto_invite, false), ("webhook", row.webhook.map(Value::String), false), ("error_handler", row.error_handler, true), @@ -418,11 +419,6 @@ pub async fn load_settings_document( row.public_app_execution_limit_per_minute.map(|n| json!(n)), true, ), - ( - "guest_access_enabled", - Some(json!(row.guest_access_enabled.unwrap_or(false))), - true, - ), ( "guest_jwt_public_key", row.guest_jwt_public_key.map(Value::String), @@ -565,7 +561,6 @@ fn same(a: Option<&Value>, b: Option<&Value>) -> bool { /// Switches the endpoint stores as `false` when cleared, compared as `false` when unset. const FALSE_WHEN_UNSET: &[&str] = &[ "mute_critical_alerts", - "guest_access_enabled", "add_admins_and_developers_to_forks", "error_handler_fallback_to_instance_alerts", ]; @@ -690,6 +685,10 @@ fn resolve_secret( } } +/// Keys earlier versions wrote that no longer configure anything. A synced `settings.yaml` +/// keeps them until its next pull, so they are dropped without the unknown-key warning. +const RETIRED_KEYS: &[&str] = &["guest_access_enabled"]; + const KNOWN_KEYS: &[&str] = &[ "auto_invite", "webhook", @@ -716,7 +715,6 @@ const KNOWN_KEYS: &[&str] = &[ "deploy_ui", "teams_command_script", "public_app_execution_limit_per_minute", - "guest_access_enabled", "guest_jwt_public_key", "guest_jwt_jwks_url", "error_handler_fallback_to_instance_alerts", @@ -1072,7 +1070,7 @@ fn plan_document( let unknown: Vec<&str> = desired .keys() .map(String::as_str) - .filter(|k| !KNOWN_KEYS.contains(k)) + .filter(|k| !KNOWN_KEYS.contains(k) && !RETIRED_KEYS.contains(k)) .collect(); if !unknown.is_empty() { warnings.push(format!("Ignored unknown settings: {}", unknown.join(", "))); @@ -1540,14 +1538,6 @@ fn plan_document( |v| json!({ "public_app_execution_limit_per_minute": v }), ); - let guest_access = null_as_false(p.managed("guest_access_enabled")); - p.simple( - "guest_access_enabled", - guest_access, - Target::Edit(SettingsEdit::GuestAccess), - |v| json!({ "guest_access_enabled": v.as_bool().unwrap_or(false) }), - ); - let (public_key, jwks_url) = ( p.managed("guest_jwt_public_key"), p.managed("guest_jwt_jwks_url"), @@ -2008,7 +1998,10 @@ mod tests { #[test] fn unset_switches_compare_as_false() { let mc = WorkspaceCrypt::new("k"); - let d = doc(json!({ "mute_critical_alerts": false, "guest_access_enabled": null })); + let d = doc(json!({ + "mute_critical_alerts": false, + "add_admins_and_developers_to_forks": null + })); assert!( plan_document(current(), d, false, &mc, &DatatableNames::default()) .unwrap() @@ -2017,6 +2010,14 @@ mod tests { ); } + #[test] + fn retired_keys_are_dropped_without_a_warning() { + let mc = WorkspaceCrypt::new("k"); + let d = doc(json!({ "guest_access_enabled": true })); + let p = plan_document(current(), d, false, &mc, &DatatableNames::default()).unwrap(); + assert!(p.changes.is_empty() && p.writes.is_empty() && p.warnings.is_empty()); + } + #[test] fn stating_one_guest_key_clears_the_other() { let mc = WorkspaceCrypt::new("k"); @@ -2033,7 +2034,6 @@ mod tests { fn mistyped_values_are_refused_rather_than_coerced() { let mc = WorkspaceCrypt::new("k"); for d in [ - json!({ "guest_access_enabled": "true" }), json!({ "add_admins_and_developers_to_forks": 1 }), json!({ "slack_oauth_client_id": 123 }), json!({ "error_handler": { "path": "f/x", "muted_on_cancel": "yes" } }), diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index bc1af69353..371eff43dd 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -76,10 +76,6 @@ pub const EXPOSE_METRICS_SETTING: &str = "expose_metrics"; pub const EXPOSE_DEBUG_METRICS_SETTING: &str = "expose_debug_metrics"; pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir"; pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth"; -/// Superadmin switch over guest sessions for the whole instance, above the per-workspace -/// one. Read from the table, uncached, by the same gates that read the workspace switch; -/// the superadmin Guests list writes it through `/settings/global/{key}` by this name. -pub const GUEST_ACCESS_DISABLED_SETTING: &str = "guest_access_disabled"; pub const JOB_ISOLATION_SETTING: &str = "job_isolation"; pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb"; pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing"; diff --git a/backend/windmill-common/src/sensitive_log_masks.rs b/backend/windmill-common/src/sensitive_log_masks.rs index c999297ec0..f01ca2427e 100644 --- a/backend/windmill-common/src/sensitive_log_masks.rs +++ b/backend/windmill-common/src/sensitive_log_masks.rs @@ -184,7 +184,11 @@ fn compile(secrets: &HashSet<String>) -> CompiledMasks { }) .collect(); + // Built once per job (its token is always a secret) and searched over little log text, so + // the default DFA's build cost dominates; a contiguous NFA searches as fast here since + // the literal prefilter does the scanning. let ac = aho_corasick::AhoCorasickBuilder::new() + .kind(Some(aho_corasick::AhoCorasickKind::ContiguousNFA)) .match_kind(aho_corasick::MatchKind::LeftmostLongest) .build(sorted.iter().map(|s| s.as_str())) .expect("failed to build aho-corasick automaton"); diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 2a266ba813..7f4c60faa3 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -906,12 +906,8 @@ pub async fn guest_count_in_window<'c, E: sqlx::Executor<'c, Database = sqlx::Po #[derive(Clone, Debug, Serialize)] pub struct GuestUsage { /// Whether this deployment can admit guests at all ([`instance_supports_guests`]). - /// Off, every other field is moot and no switch below can turn guests on. + /// Off, every other field is moot and an app in `guest` mode admits members only. pub available: bool, - /// The superadmin switch (`GUEST_ACCESS_DISABLED_SETTING`), which every workspace - /// switch sits under. Reported as stored, so a superadmin sees what they set even - /// where `available` overrules it. - pub instance_enabled: bool, /// Distinct guest emails over the trailing `window_days`. pub guest_count: i64, pub window_days: i32, @@ -931,8 +927,8 @@ pub const GUESTS_UNAVAILABLE_MESSAGE: &str = /// Whether guests can exist on this deployment at all. They cannot on the shared cloud: /// a guest is an identity Windmill itself never vouched for, admitted on the say-so of /// whoever runs the instance, which is not a call a multi-tenant deployment can make for -/// its tenants. Folded into every guest gate below, so a workspace switch or an app -/// policy left saying `guest` is inert rather than honored. +/// its tenants. Folded into every guest gate below, so an app policy left saying `guest` +/// is inert rather than honored. pub fn instance_supports_guests() -> bool { !*crate::worker::CLOUD_HOSTED } @@ -947,31 +943,7 @@ pub fn require_guest_support() -> Result<()> { } } -/// SQL for the superadmin switch alone, absent meaning on. The setting is read as text -/// before the cast so `true` and `"true"` both count. -fn instance_switch_sql() -> String { - format!( - "NOT COALESCE((SELECT (value #>> '{{}}')::boolean FROM global_settings \ - WHERE name = '{}'), false)", - crate::global_settings::GUEST_ACCESS_DISABLED_SETTING - ) -} - -/// SQL for "the instance admits guests": the superadmin switch, under -/// [`instance_supports_guests`]. -fn instance_admits_guests_sql() -> String { - if !instance_supports_guests() { - return "false".to_string(); - } - instance_switch_sql() -} - pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> { - let instance_switch = instance_switch_sql(); - let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_switch}")) - .fetch_one(db) - .await - .map_err(|e| Error::internal_err(format!("reading the instance guest switch: {e:#}")))?; let guest_count = guest_count_in_window(db).await?; let metered = guests_are_metered().await; let billable_guests = if metered { @@ -981,7 +953,6 @@ pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> { }; Ok(GuestUsage { available: instance_supports_guests(), - instance_enabled, guest_count, window_days: GUEST_WINDOW_DAYS, free_allowance: FREE_GUESTS_PER_WINDOW, @@ -1025,19 +996,19 @@ pub async fn guest_admission(conn: &mut sqlx::PgConnection, email: &str) -> Resu ))) } -/// Whether a guest session for `email` in `w_id` still stands: the instance and the -/// workspace admit guests, and the email still has no account. Read at the auth door on -/// every guest request, so turning either switch off, or an account provisioned after -/// the mint (or racing it), ends the session on its next request. +/// Whether a guest session for `email` in `w_id` still stands: the deployment admits +/// guests, and the email still has no account. Read at the auth door on every guest +/// request, so an account provisioned after the mint (or racing it) ends the session on +/// its next request. Whether its app still admits guests is the on-behalf resolver's call. pub async fn guest_session_stands(db: &crate::DB, w_id: &str, email: &str) -> Result<bool> { - let instance_admits = instance_admits_guests_sql(); - let stands: Option<bool> = sqlx::query_scalar(&format!( - "SELECT guest_access_enabled - AND {instance_admits} - AND NOT EXISTS(SELECT 1 FROM password WHERE email = $2) + if !instance_supports_guests() { + return Ok(false); + } + let stands: Option<bool> = sqlx::query_scalar( + "SELECT NOT EXISTS(SELECT 1 FROM password WHERE email = $2) AND NOT EXISTS(SELECT 1 FROM usr WHERE email = $2) - FROM workspace_settings WHERE workspace_id = $1" - )) + FROM workspace_settings WHERE workspace_id = $1", + ) .bind(w_id) .bind(email) .fetch_optional(db) @@ -1046,27 +1017,23 @@ pub async fn guest_session_stands(db: &crate::DB, w_id: &str, email: &str) -> Re Ok(stands.unwrap_or(false)) } -/// Every switch at once: the instance's, the workspace's, and `app_path` being in -/// `guest` execution mode. The single answer to "may a guest session be minted for this -/// app", used by the mint itself and by the sign-in branch that decides whether to call -/// it. A missing app or a policy with no stated mode reads as "no". The allowance is -/// `guest_admission`. +/// Whether the deployment admits guests and `app_path` is in `guest` execution mode. +/// The single answer to "may a guest session be minted for this app", used by the mint +/// itself and by the sign-in branch that decides whether to call it. A missing app or a +/// policy with no stated mode reads as "no". The allowance is `guest_admission`. pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>( executor: E, w_id: &str, app_path: &str, ) -> Result<bool> { // The mint refuses a path it cannot scope, so discovery must not advertise one. - if !crate::auth::is_scope_literal_path(app_path) { + if !instance_supports_guests() || !crate::auth::is_scope_literal_path(app_path) { return Ok(false); } - let instance_admits = instance_admits_guests_sql(); - let admits: Option<bool> = sqlx::query_scalar(&format!( - "SELECT COALESCE(ws.guest_access_enabled AND app.policy->>'execution_mode' = 'guest', false) - AND {instance_admits} - FROM app JOIN workspace_settings ws ON ws.workspace_id = app.workspace_id - WHERE app.workspace_id = $1 AND app.path = $2" - )) + let admits: Option<bool> = sqlx::query_scalar( + "SELECT COALESCE(policy->>'execution_mode' = 'guest', false) + FROM app WHERE workspace_id = $1 AND path = $2", + ) .bind(w_id) .bind(app_path) .fetch_optional(executor) diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 541369f327..3c4b032b93 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -695,6 +695,9 @@ async fn get_resource_value( Ok(Json(value)) } +/// Including the resource's and its folder's `extra_perms` in the error is intended, not a +/// leak: who holds access to an item is not secret within a workspace, and it tells the +/// denied caller whom to ask. Keep the maps in the message. pub async fn explain_resource_perm_error( path: &str, w_id: &str, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index e9aa3ab60d..3a5fccae3b 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -486,6 +486,9 @@ fn describe_authed(authed: Option<&(impl Authable + Sync)>) -> String { } } +/// Including the variable's and its folder's `extra_perms` in the error is intended, not a +/// leak: who holds access to an item is not secret within a workspace, and it tells the +/// denied caller whom to ask. Keep the maps in the message. async fn explain_variable_perm_error( path: &str, w_id: &str, diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 979e6c1dee..53a5d0a6b4 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -539,6 +539,7 @@ pub struct ExponentialDelay { pub attempts: u32, pub multiplier: u16, pub seconds: u16, + #[serde(skip_serializing_if = "Option::is_none")] pub random_factor: Option<i8>, } @@ -925,7 +926,9 @@ pub struct Branch { pub modules: Vec<FlowModule>, #[serde(skip_serializing_if = "Option::is_none")] pub modules_node: Option<FlowNodeId>, - #[serde(default = "default_true")] + // The worker reads an absent skip_failure as false (BranchWithSkipFailures), + // so a round-trip through this struct must not turn it into true. + #[serde(default)] pub skip_failure: bool, #[serde(default = "default_true")] pub parallel: bool, @@ -1700,6 +1703,39 @@ mod tests { assert_eq!(empty, json!({})); } + #[test] + fn exponential_retry_without_random_factor_omits_it() { + // A non-default exponential retry with no random_factor must not gain + // random_factor:null on a round-trip (the flow-dependencies job writes + // that value back to flow.yaml, and the CLI linter rejects null). + let input = json!({ "exponential": { "attempts": 3, "multiplier": 1, "seconds": 2 } }); + let retry: Retry = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(&retry).unwrap(), input); + + // A set random_factor still round-trips. + let input = json!({ + "exponential": { "attempts": 3, "multiplier": 1, "seconds": 2, "random_factor": 10 } + }); + let retry: Retry = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(&retry).unwrap(), input); + } + + #[test] + fn branch_without_skip_failure_keeps_failing_the_flow() { + // An absent skip_failure means "a failing branch fails the flow" to the + // worker and the flow editor; a round-trip must preserve that. + let input = json!({ + "type": "branchall", + "branches": [{ "modules": [] }, { "modules": [], "skip_failure": true }] + }); + let val: FlowModuleValue = serde_json::from_value(input).unwrap(); + let output = serde_json::to_value(&val).unwrap(); + let reparsed: FlowModuleWithBranches = serde_json::from_value(output).unwrap(); + let skips: Vec<bool> = + reparsed.branches.iter().map(|b| b.skip_failure.unwrap_or(false)).collect(); + assert_eq!(skips, vec![false, true]); + } + #[test] fn stop_after_if_omits_null_error_message() { let input = json!({ "expr": "result == 404", "skip_if_stopped": true }); diff --git a/backend/windmill-worker/src/dbt_profiles.rs b/backend/windmill-worker/src/dbt_profiles.rs index f81aea2ae5..0b316231f7 100644 --- a/backend/windmill-worker/src/dbt_profiles.rs +++ b/backend/windmill-worker/src/dbt_profiles.rs @@ -122,7 +122,7 @@ impl KnownAdapter { "duckdb" => Some(KnownAdapter::Duckdb), "clickhouse" => Some(KnownAdapter::Clickhouse), "snowflake" | "snowflake_oauth" => Some(KnownAdapter::Snowflake), - "bigquery" | "gcp_service_account" => Some(KnownAdapter::Bigquery), + "bigquery" | "google_service_account" | "gcp_service_account" => Some(KnownAdapter::Bigquery), "databricks" => Some(KnownAdapter::Databricks), "salesforce" => Some(KnownAdapter::Salesforce), "ms_sql_server" | "mssql" | "sqlserver" | "fabric" => Some(KnownAdapter::Mssql), @@ -1098,6 +1098,10 @@ mod tests { KnownAdapter::from_resource_type("fabric"), Some(KnownAdapter::Mssql) ); + assert_eq!( + KnownAdapter::from_resource_type("google_service_account"), + Some(KnownAdapter::Bigquery) + ); } // `PartialEq` covers the carried name, so two spellings of one adapter must diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index beeac41a5c..d612dce539 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -5881,15 +5881,18 @@ async fn handle_code_execution_job( // preview only, from its `RawCode`; a job queued by a server predating that may still carry // a caller's, and honoring it on a deployed runnable would run caller code as that runnable // (and as its `on_behalf_of` identity). - let modules = modules_from_data.clone().or_else(|| { - let args = job.args.as_ref().filter(|_| job.kind == JobKind::Preview); - args.and_then(|args| { - args.get(MODULES_ARG).and_then(|raw| { - serde_json::from_str::<std::collections::HashMap<String, ScriptModule>>(raw.get()) - .ok() - }) - }) - }); + let modules_from_args; + let modules = if modules_from_data.is_some() { + modules_from_data + } else { + modules_from_args = job + .args + .as_ref() + .filter(|_| job.kind == JobKind::Preview) + .and_then(|args| args.get(MODULES_ARG)) + .and_then(|raw| serde_json::from_str(raw.get()).ok()); + &modules_from_args + }; try_validate_schema( job, @@ -5924,7 +5927,7 @@ async fn handle_code_execution_job( envs, codebase, lock, - &modules, + modules, false, in_pipeline, )) diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index e0c4026804..3356793b84 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -6166,6 +6166,7 @@ pub(super) fn is_simple_modules( && modules[0].suspend.is_none() && modules[0].cache_ttl.is_none() && modules[0].retry.is_none() + && modules[0].timeout.is_none() && modules[0].stop_after_if.is_none() && modules[0].stop_after_all_iters_if.is_none() && modules[0].skip_if.is_none() @@ -6710,8 +6711,32 @@ pub async fn get_previous_job_result( #[cfg(test)] mod tests { - use super::{effective_flow_step_timeout, extract_chat_message_from_flow_result}; + use super::{ + effective_flow_step_timeout, extract_chat_message_from_flow_result, is_simple_modules, + }; use serde_json::{json, value::to_raw_value}; + use windmill_common::flows::FlowModule; + + // The simple-loop fast path reads step settings off the loop module, so a loop body whose + // step has its own timeout must take the full path or that timeout is dropped. + #[test] + fn loop_body_with_step_timeout_is_not_simple() { + let step = |extra: serde_json::Value| -> FlowModule { + let mut m = json!({ + "id": "b", + "value": {"type": "rawscript", "language": "python3", "content": "", "input_transforms": {}} + }); + m.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + serde_json::from_value(m).unwrap() + }; + assert!(is_simple_modules(&vec![step(json!({}))], None)); + assert!(!is_simple_modules( + &vec![step(json!({"timeout": {"type": "static", "value": 300}}))], + None + )); + } // A `<= 0` step timeout override must behave as "no override" and inherit the referenced // script's timeout, not collapse to a 0-second (instant-kill) timeout. A positive override diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 43e5706311..2058829d50 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.851.0"; +export const VERSION = "v1.853.0"; export async function login(email: string, password: string): Promise<string> { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 5f27f6b0bc..19dad2b987 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/chat-sdk", - "version": "1.851.0", + "version": "1.853.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/chat-sdk", - "version": "1.851.0", + "version": "1.853.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 29201bc2e5..b0d0385371 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@windmill-labs/chat-sdk", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.851.0", + "version": "1.853.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 98339865be..956f2ec5db 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -9,7 +9,7 @@ import { stringify as yamlStringify } from "yaml"; import { yamlParseFile } from "../../utils/yaml.ts"; import { readTextFile, validateRequiredArgs } from "../../utils/utils.ts"; import * as wmill from "../../../gen/services.gen.ts"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; import { buildFolderPath, getMetadataFileName, loadNonDottedPathsSetting } from "../../utils/resource_folders.ts"; import { requireLogin } from "../../core/auth.ts"; @@ -651,6 +651,10 @@ async function preview( const cwdBeforeConfig = process.cwd(); if (useLocalPathScripts) { opts = await mergeConfigWithConfigFile(opts); + } else { + // --remote skips the config read, but resolving the flow folder still + // needs the configured suffix (nonDottedPaths). + await loadNonDottedPathsSetting(); } const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -660,26 +664,7 @@ async function preview( const argPath = flowPath; flowPath = toSyncRootRelativePath(flowPath, cwdBeforeConfig); - // Normalize path - ensure it's a directory path to a .flow or __flow folder - const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP) - || flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP); - if (!isFlowDir) { - // Check if it's a flow.yaml file - if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) { - // Use dirname so a bare "flow.yaml" (no parent dir) becomes "." - // instead of "" — the latter, after appending SEP below, becomes "/" - // and silently reads from filesystem root. - flowPath = dirname(flowPath); - } else { - throw new Error( - "Flow path must be a .flow/__flow directory or a flow.yaml file" - ); - } - } - - if (!flowPath.endsWith(SEP)) { - flowPath += SEP; - } + flowPath = resolveFlowFolderArg(flowPath); // The flow's windmill path (e.g. "f/cli_smoke/myrelflow"). It is what the // preview job runs under, and the anchor for relative-import resolution: @@ -852,20 +837,13 @@ async function previewStep( // Falls through here only when the deployed PathScript is what we want — // either --remote was passed, or no local file exists for this path. log.info(colors.yellow(`Previewing step '${stepId}' (script ${moduleValue.path})...`)); - const script = moduleValue.hash - ? await wmill.getScriptByHash({ - workspace: workspace.workspaceId, - hash: moduleValue.hash, - }) - : await wmill.getScriptByPath({ - workspace: workspace.workspaceId, - path: moduleValue.path, - }); + const script = await loadStepScript(workspace.workspaceId, moduleValue); jobId = await wmill.runScriptPreview({ workspace: workspace.workspaceId, requestBody: { content: script.content, language: script.language as any, + lock: script.lock, // Anchor to the script's own deployed path so its relative imports // resolve against the workspace tree (or temp_script_refs). path: moduleValue.path, @@ -916,6 +894,54 @@ async function previewStep( } } +/** + * Resolve a `flow preview` argument to its local flow folder, with a trailing + * separator. Accepts the folder itself, its flow.yaml, or the flow's Windmill + * path (`f/x/my_flow`), which other commands take without the folder suffix. + */ +export function resolveFlowFolderArg(flowPath: string): string { + const withSep = (p: string) => (p.endsWith(SEP) ? p : p + SEP); + const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP) + || flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP); + if (isFlowDir) { + return withSep(flowPath); + } + if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) { + // dirname, so a bare "flow.yaml" becomes "." rather than "", which would + // turn into "/" and read from the filesystem root. + return withSep(dirname(flowPath)); + } + const bare = flowPath.endsWith(SEP) ? flowPath.slice(0, -SEP.length) : flowPath; + // The configured suffix first, then the other one: a workspace can hold both. + const candidates = [...new Set([buildFolderPath(bare, "flow"), bare + ".flow", bare + "__flow"])]; + const found = candidates.find((c) => existsSync(c) && statSync(c).isDirectory()); + if (found) { + return withSep(found); + } + throw new Error( + `Flow path must be a .flow/__flow directory, a flow.yaml file, or the Windmill path of a local flow; found none of ${candidates.join(", ")}` + ); +} + +/** + * Fetch the code and lockfile a `script` step runs, the way a whole-flow run + * resolves them: hub paths (`hub/<id>/...`) from the hub, others from the + * workspace. + */ +export async function loadStepScript( + workspaceId: string, + moduleValue: { path: string; hash?: string }, +): Promise<{ content: string; language: string; lock?: string }> { + if (moduleValue.path.startsWith("hub/")) { + const hub = await wmill.getHubScriptByPath({ path: moduleValue.path }); + return { content: hub.content, language: hub.language, lock: hub.lockfile }; + } + const script = moduleValue.hash + ? await wmill.getScriptByHash({ workspace: workspaceId, hash: moduleValue.hash }) + : await wmill.getScriptByPath({ workspace: workspaceId, path: moduleValue.path }); + return { content: script.content, language: script.language, lock: script.lock }; +} + // Strip the `.flow`/`__flow` directory suffix to recover the flow's logical // Windmill path. Workspaces with nonDottedPaths use `__flow`; the default // uses `.flow`. A previous version used `indexOf(".flow")` which returned -1 @@ -1204,7 +1230,7 @@ const command = new Command() .action(run as any) .command( "preview", - "preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow)." + "preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow)." ) .arguments("<flow_path:string>") .option( diff --git a/cli/src/commands/review/aiReviewCore.gen.ts b/cli/src/commands/review/aiReviewCore.gen.ts index 34d98da18f..546b82f124 100644 --- a/cli/src/commands/review/aiReviewCore.gen.ts +++ b/cli/src/commands/review/aiReviewCore.gen.ts @@ -74,7 +74,7 @@ export function formatFolderInstructions( } // What the review asks of the model and how its answer is read, for every place it runs: the -// deployment request panel here, and `wmill review` on a git sync pull request, which carries +// deployment request panel and the drafts review here, and `wmill review` on a git sync pull request, which carries // this module through cli/generate-ai-review.ts. Keep it free of `$lib` imports; the // staleness test in cli/test/ai_review_bundle_unit.test.ts fails when the CLI's copy drifts. @@ -109,32 +109,47 @@ export const TOTAL_CHAR_BUDGET = 160_000 // Under the Anthropic SDK's non-streaming pre-flight max_tokens limit. export const MAX_OUTPUT_TOKENS = 8_000 -/** What is being reviewed: a fork's deployment request, or a pull request on a git sync repo. */ -export type ReviewSubject = 'deployment_request' | 'pull_request' +/** + * What is being reviewed: a fork's deployment request, a workspace's undeployed drafts, or a + * pull request on a git sync repo. + */ +export type ReviewSubject = 'deployment_request' | 'draft' | 'pull_request' const INTRO: Record<ReviewSubject, string> = { deployment_request: 'You review a Windmill deployment request: a set of changes made in a fork workspace that will be merged into its parent workspace. Each changed item is given with its parent version (what is deployed now) and its fork version (what will be deployed).', + draft: + 'You review drafts in a Windmill workspace: changes saved to items of the workspace but not deployed yet. Each changed item is given with its deployed version (what is deployed now) and its draft version (what will be deployed).', pull_request: "You review a pull request on a git repository that Windmill's git sync keeps in step with a workspace: merging it deploys its changes. Each changed item is given with the files that make it up in the repository, each as a unified diff of the base branch (what is deployed now) against the pull request (what will be deployed). Every diff line starts with its line number: R<n> for line n of the pull request's version of the file, L<n> for line n of the base version, then + for an added line, - for a removed one and a space for unchanged context." } +const ITEM_ANSWER_SHAPE = + '{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}' + const ANSWER_SHAPE: Record<ReviewSubject, string> = { - deployment_request: - '{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}', + deployment_request: ITEM_ANSWER_SHAPE, + draft: ITEM_ANSWER_SHAPE, pull_request: '{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "file": "<the file the issue is in, as given>", "line": "<the R or L line number the issue is on, as given, such as R12, or null for the file as a whole>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}' } const SOURCE: Record<ReviewSubject, string> = { deployment_request: 'The parent workspace', + draft: 'The workspace', pull_request: "The repository's base branch" } +const APPROVED: Record<ReviewSubject, string> = { + deployment_request: 'the merge', + draft: 'the deployment', + pull_request: 'the merge' +} + export function reviewSystemPrompt(subject: ReviewSubject): string { return `${INTRO[subject]} An item can be a script, flow, app, schedule, trigger, folder, resource type, datatable migration, variable or resource. -Report only issues a reviewer should act on before approving the merge: +Report only issues a reviewer should act on before approving ${APPROVED[subject]}: - bugs and logic errors introduced by the change - breaking changes: removed or renamed inputs, changed input types, changed outputs that callers may rely on - risky operational changes: schedules retimed, triggers pointed at a different script or flow, folder owners or permissions, app policies, destructive migrations diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index cdf5a1ba1d..f4962d3a65 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -14,7 +14,7 @@ import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { Confirm } from "@cliffy/prompt/confirm"; import * as log from "../../core/log.ts"; -import { SETTINGS_MANAGED_ONLY_WHEN_PRESENT } from "../../core/settings.ts"; +import { RETIRED_SETTINGS, SETTINGS_MANAGED_ONLY_WHEN_PRESENT } from "../../core/settings.ts"; import * as path from "node:path"; import { sep as SEP } from "node:path"; import { @@ -2606,6 +2606,10 @@ const isWorkspaceSettingsFile = (p: string) => function stripUnappliedSettingsFields(local: any, remote: any) { delete local?.name; delete remote?.name; + for (const key of RETIRED_SETTINGS) { + delete local?.[key]; + delete remote?.[key]; + } for (const key of ["operator_settings", ...SETTINGS_MANAGED_ONLY_WHEN_PRESENT]) { if (local?.[key] === undefined) delete remote?.[key]; } diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index b46897b9b5..495249b020 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -338,14 +338,14 @@ export async function pushNativeTrigger( } } -const triggerTemplates: Record<TriggerType, Record<string, any>> = { +// Scaffold only fields the backend reads: `mode` supersedes the deprecated `enabled`, and +// `request_type` the deprecated `is_async`. +export const triggerTemplates: Record<TriggerType, Record<string, any>> = { http: { script_path: "", is_flow: false, route_path: "", http_method: "get", - is_async: false, - requires_auth: true, request_type: "sync", authentication_method: "none", is_static_website: false, @@ -360,7 +360,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = { filters: [], can_return_message: false, can_return_error_result: false, - enabled: false, + mode: "disabled", }, kafka: { script_path: "", @@ -369,7 +369,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = { group_id: "", topics: [], filters: [], - enabled: false, + mode: "disabled", }, nats: { script_path: "", @@ -377,7 +377,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = { nats_resource_path: "", subjects: [], use_jetstream: false, - enabled: false, + mode: "disabled", }, postgres: { script_path: "", @@ -385,21 +385,21 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = { postgres_resource_path: "", publication_name: "", replication_slot_name: "", - enabled: false, + mode: "disabled", }, mqtt: { script_path: "", is_flow: false, mqtt_resource_path: "", subscribe_topics: [], - enabled: false, + mode: "disabled", }, amqp: { script_path: "", is_flow: false, amqp_resource_path: "", queue_name: "", - enabled: false, + mode: "disabled", }, sqs: { script_path: "", @@ -407,7 +407,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = { queue_url: "", aws_resource_path: "", aws_auth_resource_type: "credentials", - enabled: false, + mode: "disabled", }, gcp: { script_path: "", @@ -417,7 +417,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = { subscription_id: "", delivery_type: "pull", subscription_mode: "create_update", - enabled: false, + mode: "disabled", }, azure: { script_path: "", @@ -426,13 +426,13 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = { azure_mode: "namespace_pull", scope_resource_id: "", subscription_name: "", - enabled: false, + mode: "disabled", }, email: { script_path: "", is_flow: false, local_part: "", - enabled: false, + mode: "disabled", }, }; diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 32531c2056..828dbf6bc6 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.851.0"; +export const VERSION = "1.853.0"; diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 94875d2659..9891440dd4 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -72,7 +72,6 @@ export interface SimplifiedSettings { deploy_ui?: any; teams_command_script?: string | null; public_app_execution_limit_per_minute?: number | null; - guest_access_enabled?: boolean; guest_jwt_public_key?: string | null; guest_jwt_jwks_url?: string | null; error_handler_fallback_to_instance_alerts?: boolean; @@ -89,7 +88,6 @@ export const SETTINGS_MANAGED_ONLY_WHEN_PRESENT = [ "deploy_ui", "teams_command_script", "public_app_execution_limit_per_minute", - "guest_access_enabled", "guest_jwt_public_key", "guest_jwt_jwks_url", "error_handler_fallback_to_instance_alerts", @@ -97,6 +95,10 @@ export const SETTINGS_MANAGED_ONLY_WHEN_PRESENT = [ "variable_expiration_handler", ] as const; +/** Keys earlier versions wrote that no longer configure anything; the server ignores them, + * so a file that still carries one must not show as differing from the workspace. */ +export const RETIRED_SETTINGS = ["guest_access_enabled"] as const; + // Legacy settings interface for reading old settings.yaml files interface LegacySimplifiedSettings { auto_invite_enabled?: boolean; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index ce715fe46a..537f531523 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5790,7 +5790,7 @@ Add \`--description "..."\` when the user provided a longer explanation worth pr Open the generated \`flow.yaml\` (under the folder the command just created) and replace the empty \`value.modules\` + \`schema\` with the real flow definition. -For rawscript modules, use \`!inline path/to/script.ts\` for the content key. {{INLINE_SCRIPT_NAMING}} +For rawscript modules, set the content key to the quoted string \`content: '!inline path/to/script.ts'\`. Unquoted, \`!inline\` is a YAML tag, which \`wmill lint\` rejects. {{INLINE_SCRIPT_NAMING}} Once the flow has real content, **offer** to open the visual preview as a one-sentence next step (e.g. "Want me to open the visual preview?"). Don't auto-open — opening the dev page has side effects (browser window, possibly a \`launch.json\` entry) and the user should consent. @@ -6465,7 +6465,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"requires_approval":{"type":"boolean","description":"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"},{"$ref":"#/components/schemas/QuestionToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue","question":"#/components/schemas/QuestionToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"QuestionToolValue":{"type":"object","description":"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.","properties":{"tool_type":{"type":"string","enum":["question"]},"timeout":{"type":"integer","description":"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800."}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n\`flow_input.model\` to choose the model per run.\\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside \`provider\`.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\\nand, optionally, the \`model\` and \`reasoning_effort\` overrides of what the agent saved.\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default \`flow\`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["approval"]},"call_id":{"type":"string"},"calls":{"type":"array","items":{"type":"object","properties":{"function_name":{"type":"string"},"arguments":{}},"required":["function_name","arguments"]}},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","calls","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["question"]},"call_id":{"type":"string"},"question":{"type":"string"},"choices":{"type":"array","items":{"type":"string"},"description":"answers the model proposes; a free-text answer is always possible"},"multi_select":{"type":"boolean","description":"whether several choices can be picked together"},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","question","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'"}},"required":["expr"]},"sleep":{"description":"Delay after this step completes, before the next step starts (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"requires_approval":{"type":"boolean","description":"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"},{"$ref":"#/components/schemas/QuestionToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue","question":"#/components/schemas/QuestionToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"QuestionToolValue":{"type":"object","description":"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.","properties":{"tool_type":{"type":"string","enum":["question"]},"timeout":{"type":"integer","description":"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800."}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n\`flow_input.model\` to choose the model per run.\\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside \`provider\`.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\\nand, optionally, the \`model\` and \`reasoning_effort\` overrides of what the agent saved.\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default \`flow\`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["approval"]},"call_id":{"type":"string"},"calls":{"type":"array","items":{"type":"object","properties":{"function_name":{"type":"string"},"arguments":{}},"required":["function_name","arguments"]}},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","calls","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["question"]},"call_id":{"type":"string"},"question":{"type":"string"},"choices":{"type":"array","items":{"type":"string"},"description":"answers the model proposes; a free-text answer is always possible"},"multi_select":{"type":"boolean","description":"whether several choices can be picked together"},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","question","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} `, "raw-app": `--- name: raw-app @@ -8509,7 +8509,7 @@ write a secret you were not given: keep the placeholder. | \`slack_command_script\` / \`teams_command_script\` | Script or flow handling the Slack / Teams command. | | \`slack_oauth_client_id\` / \`slack_oauth_client_secret\` | The workspace's own Slack OAuth app. Clearing either removes both. | | \`public_app_execution_limit_per_minute\` | Rate limit for public apps, \`null\` for none. | -| \`guest_access_enabled\`, \`guest_jwt_public_key\`, \`guest_jwt_jwks_url\` | Guest access to public apps and the key that signs guest JWTs. | +| \`guest_jwt_public_key\`, \`guest_jwt_jwks_url\` | The key that signs guest JWTs for apps set to Guests. | | \`add_admins_and_developers_to_forks\` | Add the workspace's admins and developers to each fork. | | \`color\` | Workspace color, \`#rrggbb\`. | @@ -8527,8 +8527,8 @@ With \`includeSettings: true\` in \`wmill.yaml\`, \`wmill sync pull\` writes the \`wmill sync push\` applies it. Unlike a partial apply, a push treats the file as the whole document: a setting the file omits is cleared, except \`operator_settings\`, \`color\`, the auto-invite instance groups, and the keys \`ducklake\`, \`dbt_warehouses\`, \`deploy_ui\`, \`teams_command_script\`, -\`public_app_execution_limit_per_minute\`, \`guest_access_enabled\`, \`guest_jwt_public_key\`, -\`guest_jwt_jwks_url\`, \`error_handler_fallback_to_instance_alerts\` and +\`public_app_execution_limit_per_minute\`, \`guest_jwt_public_key\`, \`guest_jwt_jwks_url\`, +\`error_handler_fallback_to_instance_alerts\` and \`add_admins_and_developers_to_forks\`, which are managed only when the file states them. \`datatable_permissions\` is pushed only with \`includeDatatablePermissions: true\` (or \`--include-datatable-permissions\`). A push ignores \`protection_rules\`: \`protection-rules.yaml\` and @@ -8718,7 +8718,7 @@ flow related commands - \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting. - \`--tag <tag:string>\` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). -- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). +- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). - \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. diff --git a/cli/test/ai_review_bundle_unit.test.ts b/cli/test/ai_review_bundle_unit.test.ts index 03ba770d6d..a1508050ff 100644 --- a/cli/test/ai_review_bundle_unit.test.ts +++ b/cli/test/ai_review_bundle_unit.test.ts @@ -140,8 +140,9 @@ describe("wmill review prompt", () => { "encryption_key.yaml": "HEAD-ENCRYPTION-KEY\n", "f/a/notes.prod.resource.yaml": "resource_type: postgresql\nvalue:\n password: PROD-PASSWORD\n", "f/a/files.fileset/x.flow/flow.yaml": "password: FILESET-PASSWORD-2\n", - // A pathspec matching every changed file, if git were let read it as one. - "*": "star\n", + // A pathspec matching every changed file, if git were let read it as one. Windows + // forbids `*` in a file name. + ...(process.platform === "win32" ? {} : { "*": "star\n" }), }, (base, head) => { const { system, user, instructions } = buildReviewPrompt(base, head); diff --git a/cli/test/flow_preview_unit.test.ts b/cli/test/flow_preview_unit.test.ts new file mode 100644 index 0000000000..0105ea2e71 --- /dev/null +++ b/cli/test/flow_preview_unit.test.ts @@ -0,0 +1,137 @@ +/** + * `wmill flow preview` argument resolution and `--step` script loading. + */ +import { expect, test, describe, beforeEach, afterEach } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { mockServices } from "./mock_services.ts"; + +const calls: { fn: string; args: any }[] = []; +mockServices({ + getHubScriptByPath: async (args: any) => { + calls.push({ fn: "getHubScriptByPath", args }); + return { + content: "export async function main() { return 'hub' }", + language: "deno", + lockfile: "hub-lock", + }; + }, + getScriptByPath: async (args: any) => { + calls.push({ fn: "getScriptByPath", args }); + return { content: "workspace code", language: "bun", lock: "ws-lock" }; + }, + getScriptByHash: async (args: any) => { + calls.push({ fn: "getScriptByHash", args }); + return { content: "hashed code", language: "python3", lock: "hash-lock" }; + }, +}); + +const { resolveFlowFolderArg, loadStepScript } = await import( + "../src/commands/flow/flow.ts" +); +const { setNonDottedPaths } = await import("../src/utils/resource_folders.ts"); + +const SEP = path.sep; + +describe("resolveFlowFolderArg", () => { + let root: string; + let previousCwd: string; + + beforeEach(() => { + previousCwd = process.cwd(); + root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "wmill_flow_preview_")), + ); + fs.mkdirSync(path.join(root, "f", "t", "dotted.flow"), { recursive: true }); + fs.mkdirSync(path.join(root, "f", "t", "nondotted__flow"), { recursive: true }); + process.chdir(root); + }); + + afterEach(() => { + process.chdir(previousCwd); + fs.rmSync(root, { recursive: true, force: true }); + }); + + test("a Windmill path resolves to its local .flow folder", () => { + expect(resolveFlowFolderArg(`f${SEP}t${SEP}dotted`)).toBe( + `f${SEP}t${SEP}dotted.flow${SEP}`, + ); + }); + + test("a Windmill path resolves to a __flow folder when that is the one on disk", () => { + expect(resolveFlowFolderArg(`f${SEP}t${SEP}nondotted`)).toBe( + `f${SEP}t${SEP}nondotted__flow${SEP}`, + ); + }); + + test("when both folders exist, the configured suffix wins", () => { + fs.mkdirSync(path.join(root, "f", "t", "both.flow"), { recursive: true }); + fs.mkdirSync(path.join(root, "f", "t", "both__flow"), { recursive: true }); + try { + setNonDottedPaths(true); + expect(resolveFlowFolderArg(`f${SEP}t${SEP}both`)).toBe( + `f${SEP}t${SEP}both__flow${SEP}`, + ); + setNonDottedPaths(false); + expect(resolveFlowFolderArg(`f${SEP}t${SEP}both`)).toBe( + `f${SEP}t${SEP}both.flow${SEP}`, + ); + } finally { + setNonDottedPaths(false); + } + }); + + test("the folder and its flow.yaml keep resolving to the folder", () => { + const folder = `f${SEP}t${SEP}dotted.flow${SEP}`; + expect(resolveFlowFolderArg(`f${SEP}t${SEP}dotted.flow`)).toBe(folder); + expect(resolveFlowFolderArg(folder)).toBe(folder); + expect(resolveFlowFolderArg(`${folder}flow.yaml`)).toBe(folder); + expect(resolveFlowFolderArg("flow.yaml")).toBe(`.${SEP}`); + }); + + test("a Windmill path with no local flow folder is refused, naming what was looked for", () => { + expect(() => resolveFlowFolderArg(`f${SEP}t${SEP}missing`)).toThrow( + `found none of f${SEP}t${SEP}missing.flow, f${SEP}t${SEP}missing__flow`, + ); + }); +}); + +describe("loadStepScript", () => { + beforeEach(() => { + calls.length = 0; + }); + + test("a hub path is fetched from the hub, with the hub's lockfile", async () => { + const script = await loadStepScript("ws", { + path: "hub/29624/slack/send_message_to_channel", + }); + expect(calls).toEqual([ + { + fn: "getHubScriptByPath", + args: { path: "hub/29624/slack/send_message_to_channel" }, + }, + ]); + expect(script).toEqual({ + content: "export async function main() { return 'hub' }", + language: "deno", + lock: "hub-lock", + }); + }); + + test("a workspace path is fetched from the workspace, with its deployed lock", async () => { + const script = await loadStepScript("ws", { path: "f/t/helper" }); + expect(calls).toEqual([ + { fn: "getScriptByPath", args: { workspace: "ws", path: "f/t/helper" } }, + ]); + expect(script).toEqual({ content: "workspace code", language: "bun", lock: "ws-lock" }); + }); + + test("a pinned hash is fetched by hash", async () => { + const script = await loadStepScript("ws", { path: "f/t/helper", hash: "abc" }); + expect(calls).toEqual([ + { fn: "getScriptByHash", args: { workspace: "ws", hash: "abc" } }, + ]); + expect(script).toEqual({ content: "hashed code", language: "python3", lock: "hash-lock" }); + }); +}); diff --git a/cli/test/trigger_templates_unit.test.ts b/cli/test/trigger_templates_unit.test.ts new file mode 100644 index 0000000000..736fc7e010 --- /dev/null +++ b/cli/test/trigger_templates_unit.test.ts @@ -0,0 +1,44 @@ +/** + * `wmill trigger new` scaffolds only fields the backend reads: `request_type` + * rather than the deprecated `is_async`, no `requires_auth` (its column was + * renamed to `authentication_method`), and `mode` rather than the deprecated + * `enabled`. + */ +import { expect, test, describe } from "bun:test"; +import { triggerTemplates } from "../src/commands/trigger/trigger.ts"; + +describe("trigger new templates", () => { + test("the http template has no is_async or requires_auth", () => { + const http = triggerTemplates.http; + expect(Object.keys(http)).not.toContain("is_async"); + expect(Object.keys(http)).not.toContain("requires_auth"); + expect(http.request_type).toBe("sync"); + expect(http.authentication_method).toBe("none"); + }); + + test("no template scaffolds the deprecated enabled field", () => { + const withEnabled = Object.entries(triggerTemplates) + .filter(([, template]) => "enabled" in template) + .map(([kind]) => kind); + expect(withEnabled).toEqual([]); + }); + + test("templates that scaffolded a disabled trigger still do, through mode", () => { + const modes = Object.fromEntries( + Object.entries(triggerTemplates).map(([kind, t]) => [kind, t.mode]), + ); + expect(modes).toEqual({ + http: undefined, + websocket: "disabled", + kafka: "disabled", + nats: "disabled", + postgres: "disabled", + mqtt: "disabled", + amqp: "disabled", + sqs: "disabled", + gcp: "disabled", + azure: "disabled", + email: "disabled", + }); + }); +}); diff --git a/docs/dbt-runtime.md b/docs/dbt-runtime.md index 6f7dc5871e..137430c600 100644 --- a/docs/dbt-runtime.md +++ b/docs/dbt-runtime.md @@ -236,7 +236,7 @@ points at the object-storage resource and a DuckLake names its catalog. **What a warehouse may point at.** Either a Windmill connection resource whose type `render_profile` translates (`postgresql`, `redshift`, `mysql`, `snowflake`, -`snowflake_oauth`, `bigquery`, `gcp_service_account`, `databricks`), or a +`snowflake_oauth`, `bigquery`, `google_service_account`, `gcp_service_account`, `databricks`), or a **`dbt_profile`** resource, whose VALUE IS one entry of that file's `outputs` map — `type` included, nothing lifted out or renamed. A block is copied from a working `profiles.yml` and pasted in, which is the whole point: a type that asked diff --git a/docs/operator-builder-rights.md b/docs/operator-builder-rights.md index d4dc42b2d3..08ce69b6ba 100644 --- a/docs/operator-builder-rights.md +++ b/docs/operator-builder-rights.md @@ -202,8 +202,8 @@ already authors elsewhere must not be charged again. A change that adds no seat - A builder may make an app public (`ExecutionMode::Anonymous`): anyone with the URL runs the runnables its policy names, as the builder. Refusing it is the workspace's call, through the `RestrictAnonymousAppDeployment` protection rule. A `Guest` app is the same with a login in - front: anyone the instance's identity provider admits, member or not, once guest access is - enabled. `RestrictGuestAppDeployment` refuses it. + front: anyone the instance's identity provider admits, member or not. + `RestrictGuestAppDeployment` refuses it. - A builder raw app may set `allow_user_resources` on a runnable's inputs, and the bundle picks the `$res:` path it sends. `build_args` resolves that path as the *viewer*, with no prompt, into a job that runs as the builder. Getting a value out takes a guessable path and a readable script diff --git a/docs/perf-profiling.md b/docs/perf-profiling.md new file mode 100644 index 0000000000..2b4cadfe9e --- /dev/null +++ b/docs/perf-profiling.md @@ -0,0 +1,83 @@ +# Profiling the per-job path + +How to find and prove a performance improvement on the job path, which is what any run of a +script or flow pays: push, pull, execution, log writes, completion. The method finds candidates +by measuring rather than by reading code, and keeps only changes whose effect was measured +before and after. A candidate that cannot be measured is not a finding. + +## The harness + +`backend/tests/perf_profile.rs` runs real jobs through the real queue, worker and database, all +in the test process. Its tests are `#[ignore]`d, so CI only compiles them. For each workload it +reports the following, separately for the idle, push and worker phases: + +- requested allocation bytes and allocation count per job (raw, and with the idle rate subtracted) +- allocation sites, sampled once every 128 KiB allocated and once every 2048 allocations. Each + site is shown as the deepest Windmill `file:line` plus the library call it allocated in + (`LEAF`), and as the call chain of the four deepest Windmill frames (`CHAIN`) +- with `PERF_SQL=1`, instead of allocations: every SQL statement, how many times it runs per job, + and its database time per job + +Workloads: a deployed bash script, a deployed Bun script, a deployed 3-step identity flow and a +deployed 3-step bash flow. To profile another path, add a workload with the same shape: create +the script or flow with SQL, then call `profile_workload`. Typical additions are a Python +script, a script with many args, a flow with a branch or a loop, or a job with a large result. + +```bash +cd backend && source ../.env.local # DATABASE_URL: sqlx::test creates its DBs from it +OUT=/tmp/perf && mkdir -p $OUT +# allocations and sites +PERF_OUT=$OUT PERF_N=30 cargo test --features quickjs --test perf_profile -- --ignored --nocapture --test-threads=1 +# SQL statements per job (a separate run, see the caveats below) +PERF_SQL=1 PERF_OUT=$OUT/sql PERF_N=30 cargo test --features quickjs --test perf_profile -- --ignored --nocapture --test-threads=1 +grep -h '^===' $OUT/*.txt # the per-job totals, one line per phase +``` + +Use the same `--features` as the running dev backend so the build reuses its artifacts. One +workload takes about 20 s. + +## Reading the output + +- **Bytes per job is the main metric.** It is very repeatable, varying less than 0.1% between + runs on the same code, so 3 runs before and 3 after a change are enough. Time per job is noisy + in a debug build and is not a measure of anything. +- **A site that sits on top of every script workload deserves attention first.** Per-job setup + that costs the same however small the job is matters most, because most jobs are small. +- **Discount `pin<…>` / `Box::pin` sites.** They are async state machines, which are many times + larger without optimisation. Confirm one in release before acting on it. +- **SQL statements per job is usually the larger lever.** Each statement is a network round trip + in production. Look for statements repeated per job or per step that read something already + known (a cached value, a row read earlier in the same job), and for appends that could be + merged. + +## A/B a candidate + +1. Run the relevant workload 3 times on `main` and keep the `WORKER` (or `PUSH`) lines. +2. Apply the change, then run it 3 times again. +3. When the cost is CPU in a library, also confirm it in a release build: a small standalone + crate in `/tmp` that pins the same crate version (see `Cargo.lock`), with a counting global + allocator and a timing loop. Debug numbers measure allocations reliably, but they do not + measure CPU. +4. Run the tests that pin the behaviour you touched, and put the before/after table in the PR. + +Changes to job completion, the queue pull or the flow state machine are the critical path. Test +them beyond the profiler: the existing suites for that area, concurrency, and older Postgres +versions. + +## Caveats + +- **Split DWARF.** In dev builds a frame's location is a relative `./windmill-*/src/…:line`, and + inlined frames have bare names (`poll<…>`). The harness keys sites by location for that reason. + Do not switch it to keying by symbol name. +- **SQL counting changes the allocation numbers.** Once `sqlx::query` events are enabled, sqlx + formats a summary for every statement. Never read allocations from a `PERF_SQL=1` run. +- **The sampler's own work is excluded.** `IN_SAMPLER` stops the backtrace's allocations from + being counted. Keep it if you change how samples are captured, or idle reads tens of MB/s. +- **`RUST_BACKTRACE` and `RUST_LIB_BACKTRACE` must be unset (or `0`).** Sampling takes std's + backtrace lock, which is not reentrant. With either variable enabled, errors capture + backtraces of their own, and an allocation made under that lock can deadlock the sampler. + The harness refuses to start when either is set. +- **Deployed Bun scripts need a NULL lock.** `''` fails at run time with "expected //bun.lock + split pattern". +- **Disk.** The test build shares `target/` with the dev backend, but the first build also + compiles the test-only dependencies. Check `df -h /` first. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d1303c0bac..38b6ac4d45 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.851.0", + "version": "1.853.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.851.0", + "version": "1.853.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 1806625104..2ce1e12034 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.851.0", + "version": "1.853.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 2dc0b1fa59..a4e9495ce3 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -42,6 +42,9 @@ import { userStore, userWorkspaces } from '$lib/stores' import { capabilitiesIn } from '$lib/roleCapabilities' import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel' + import { copilotInfo } from '$lib/aiStore' + import AiReview from './deploymentRequest/AiReview.svelte' + import { draftReviewItem } from './deploymentRequest/draftReviewItem' interface Props { currentWorkspaceId: string @@ -456,6 +459,13 @@ selectedItems = [] } + // Your own drafts only: another user's draft is view-only, and reading one returns yours. + const reviewItems = $derived( + visibleItems + .filter((i) => selectedItems.includes(i.key) && i.mine && i.draftKind !== 'data_pipeline') + .map((i) => draftReviewItem(i.draftKind, i.path, currentWorkspaceId, i.draft_only)) + ) + // --- Diff --- let diffDrawer: DiffDrawer | undefined = $state(undefined) let isFlow = $state(false) @@ -480,7 +490,13 @@ mode: 'simple', original: deployed as any, current: draft as any, - title: 'Deployed → Draft' + title: 'Deployed → Draft', + aiReview: { + workspaceId: currentWorkspaceId, + kind: item.draftKind, + path: item.path, + added: item.draft_only + } }) } @@ -912,35 +928,45 @@ {/snippet} {#snippet footer()} - <div class="flex flex-col items-end gap-2"> - <div class="flex items-center gap-2"> - <Button - variant="default" - destructive - disabled={discardableCount === 0 || deploying || discarding} - loading={discarding} - startIcon={{ icon: Undo2 }} - onClick={onDiscardSelectedClick} - > - Discard {discardableCount} draft{discardableCount !== 1 ? 's' : ''} - </Button> - <Button - variant="accent" - disabled={deployableCount === 0 || deploying || discarding || !deployPerm.ok} - title={!deployPerm.ok ? deployPerm.reason : undefined} - loading={deploying} - onClick={deploySelected} - > - Deploy {deployableCount} draft{deployableCount !== 1 ? 's' : ''} - </Button> + <div class="flex flex-col gap-4"> + <div class="flex flex-col items-end gap-2"> + <div class="flex items-center gap-2"> + <Button + variant="default" + destructive + disabled={discardableCount === 0 || deploying || discarding} + loading={discarding} + startIcon={{ icon: Undo2 }} + onClick={onDiscardSelectedClick} + > + Discard {discardableCount} draft{discardableCount !== 1 ? 's' : ''} + </Button> + <Button + variant="accent" + disabled={deployableCount === 0 || deploying || discarding || !deployPerm.ok} + title={!deployPerm.ok ? deployPerm.reason : undefined} + loading={deploying} + onClick={deploySelected} + > + Deploy {deployableCount} draft{deployableCount !== 1 ? 's' : ''} + </Button> + </div> + {#if !deployPerm.ok} + <span class="text-xs text-yellow-600">{deployPerm.reason}</span> + {:else if undeployableSelectedCount > 0} + <span class="text-xs text-secondary"> + {undeployableSelectedCount} selected draft{undeployableSelectedCount !== 1 + ? 's' + : ''} + can't be deployed (no write permission on the path) but can still be discarded + </span> + {/if} </div> - {#if !deployPerm.ok} - <span class="text-xs text-yellow-600">{deployPerm.reason}</span> - {:else if undeployableSelectedCount > 0} - <span class="text-xs text-secondary"> - {undeployableSelectedCount} selected draft{undeployableSelectedCount !== 1 ? 's' : ''} - can't be deployed (no write permission on the path) but can still be discarded - </span> + {#if $copilotInfo.enabled} + <AiReview + scope={{ kind: 'drafts', workspaceId: currentWorkspaceId, items: reviewItems }} + source="drafts" + /> {/if} </div> {/snippet} diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 8868813958..06b691679c 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -1854,9 +1854,12 @@ <!-- The request form and an open request carry their own review. --> {#if mergeIntoParent && $copilotInfo.enabled && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()} <AiReview - diffs={selectedDiffs} - forkWorkspaceId={currentWorkspaceId} - {parentWorkspaceId} + scope={{ + kind: 'deployment', + diffs: selectedDiffs, + forkWorkspaceId: currentWorkspaceId, + parentWorkspaceId + }} source="selection" /> {/if} diff --git a/frontend/src/lib/components/DiffDrawer.svelte b/frontend/src/lib/components/DiffDrawer.svelte index 2e5810e036..b7b5aa8922 100644 --- a/frontend/src/lib/components/DiffDrawer.svelte +++ b/frontend/src/lib/components/DiffDrawer.svelte @@ -14,7 +14,10 @@ import { sendUserToast } from '$lib/toast' import type { Script } from '$lib/gen' import Select from './select/Select.svelte' - import type { DiffVersionOption } from './diff_drawer' + import type { DiffAiReview, DiffVersionOption } from './diff_drawer' + import { copilotInfo } from '$lib/aiStore' + import AiReview from './deploymentRequest/AiReview.svelte' + import type { ReviewScope } from './deploymentRequest/aiReview' type DiffData = { lang?: string @@ -36,6 +39,25 @@ let diffViewer: Drawer | undefined = $state(undefined) + let aiReviewScope: ReviewScope | undefined = $state(undefined) + /** Normal mode: what the review is rebuilt from when the version picker swaps the left side. */ + let aiReviewTarget: { review: DiffAiReview; current: Value } | undefined = undefined + + function reviewScope(review: DiffAiReview, before: Value, after: Value): ReviewScope { + return { + kind: 'drafts', + workspaceId: review.workspaceId, + items: [ + { + kind: review.kind, + path: review.path, + change: review.added ? 'added' : 'modified', + load: async () => ({ before, after }) + } + ] + } + } + interface Props { restoreDeployed?: () => Promise<void> isFlow?: boolean @@ -77,6 +99,8 @@ if (token == null) openingToken++ data = undefined diffType = undefined + aiReviewScope = undefined + aiReviewTarget = undefined diffViewer?.openDrawer() } @@ -111,6 +135,8 @@ loadingVersion = false data = undefined diffType = undefined + aiReviewScope = undefined + aiReviewTarget = undefined diffViewer?.closeDrawer() } @@ -209,6 +235,11 @@ deployed: prepareDiff(value), deployedLabel: opt?.isHead ? headLabel : opt?.label } + // The review covers the version on screen; a new scope also drops a result for the + // previous one. + if (aiReviewTarget) { + aiReviewScope = reviewScope(aiReviewTarget.review, value, aiReviewTarget.current) + } } catch (e: any) { // The snap-back would otherwise be the only sign the version never loaded. if (generation === versionLoadGeneration) { @@ -270,6 +301,7 @@ current: Value defaultDiffType?: 'deployed' | 'draft' button?: { text: string; onClick: () => void } + aiReview?: DiffAiReview } | { mode: 'simple' @@ -277,6 +309,7 @@ current: Value title: string button?: { text: string; onClick: () => void } + aiReview?: DiffAiReview }, token?: number ) { @@ -284,6 +317,8 @@ // caller with none is taking the drawer, so it claims one. if (token != null && token !== openingToken) return if (token == null) openingToken++ + aiReviewScope = undefined + aiReviewTarget = undefined if (diff.mode === 'normal') { const { deployed, @@ -326,6 +361,13 @@ // The draft-vs-current view (and its tab) is obsolete — always show the // deployed-vs-current diff. diffType = 'deployed' + if (diff.aiReview) { + aiReviewTarget = { + review: { ...diff.aiReview, added: diff.aiReview.added || !!deployed.draft_only }, + current + } + aiReviewScope = reviewScope(aiReviewTarget.review, deployed, current) + } } else { const { original, current, title, button } = diff data = { @@ -336,6 +378,7 @@ button } diffType = 'custom' + if (diff.aiReview) aiReviewScope = reviewScope(diff.aiReview, original, current) } } </script> @@ -343,6 +386,14 @@ <Drawer bind:this={diffViewer} size="1200px" on:close> <DrawerContent title="Diff" on:close={diffViewer.closeDrawer}> <div class="flex flex-col gap-4 h-full"> + {#if data && aiReviewScope && $copilotInfo.enabled} + <div class="max-h-[40vh] overflow-y-auto shrink-0"> + <!-- A new diff is a new review: a result never carries over to it. --> + {#key aiReviewScope} + <AiReview scope={aiReviewScope} source="draft_diff" /> + {/key} + </div> + {/if} {#if data} <!-- Outside the `contentType` check on purpose: with no differences against the head, picking an older version is exactly how the reader finds one, diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index 4d21e81b29..a52b09c411 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -178,7 +178,12 @@ mode: 'simple', title: `${fullLabel(owner)}'s draft vs deployed`, original: deployed, - current: draftValue as any + current: draftValue as any, + // Your own draft only: a fix made from the review edits your draft, not theirs. + aiReview: + currentUsername && owner.username === currentUsername + ? { workspaceId: workspace, kind: itemKind, path, added: draft_only } + : undefined }) } catch (e: any) { sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true) diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 3246f9fd74..a1d4f29b13 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -1357,7 +1357,10 @@ ...currentFlow, path: $pathStore, draft_triggers: currentDraftTriggers - } + }, + aiReview: opWorkspace + ? { workspaceId: opWorkspace, kind: 'flow', path: $pathStore } + : undefined }, opening ) diff --git a/frontend/src/lib/components/GlobalUserOffboardingModal.svelte b/frontend/src/lib/components/GlobalUserOffboardingModal.svelte index bc2cc5b996..38bbf7b191 100644 --- a/frontend/src/lib/components/GlobalUserOffboardingModal.svelte +++ b/frontend/src/lib/components/GlobalUserOffboardingModal.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { Alert, Button } from '$lib/components/common' import { fade } from 'svelte/transition' - import { classNames } from '$lib/utils' + import { apiErrorMessage, classNames } from '$lib/utils' import { AlertTriangle, CornerDownLeft, Loader2 } from 'lucide-svelte' import { UserService, FolderService } from '$lib/gen' import type { WorkspaceOffboardPreview } from '$lib/gen' @@ -99,7 +99,7 @@ wsConfigs[workspace_id] = config } } catch (e) { - sendUserToast('Failed to load offboard preview', true) + sendUserToast(`Failed to load offboard preview: ${apiErrorMessage(e)}`, true) onClose() } finally { loading = false @@ -180,7 +180,7 @@ onComplete() } } catch (e) { - sendUserToast(`Offboarding failed: ${e}`, true) + sendUserToast(`Offboarding failed: ${apiErrorMessage(e)}`, true) } finally { submitting = false } diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 5b42f0b1f4..5589b6f16c 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -1156,7 +1156,10 @@ }) }, draft: savedScript['draft'], - current + current, + aiReview: opWorkspace + ? { workspaceId: opWorkspace, kind: 'script', path: script.path } + : undefined }, opening ) diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index eb5559a2aa..4173045853 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -813,7 +813,6 @@ loading={guestLoading} onLoadMore={() => loadGuestPage(Math.floor((guestList?.guests.length ?? 0) / guestPerPage) + 1)} - onInstanceSwitch={() => loadGuestPage(1)} /> {/if} </div> diff --git a/frontend/src/lib/components/UserOffboardingModal.svelte b/frontend/src/lib/components/UserOffboardingModal.svelte index 26d4e83256..c4dbbc537c 100644 --- a/frontend/src/lib/components/UserOffboardingModal.svelte +++ b/frontend/src/lib/components/UserOffboardingModal.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { Alert, Button } from '$lib/components/common' import { fade } from 'svelte/transition' - import { classNames } from '$lib/utils' + import { apiErrorMessage, classNames } from '$lib/utils' import { AlertTriangle, CornerDownLeft, Loader2 } from 'lucide-svelte' import { UserService, FolderService } from '$lib/gen' import type { OffboardPreview } from '$lib/gen/types.gen' @@ -90,7 +90,7 @@ doReassign = false } } catch (e) { - sendUserToast('Failed to load offboard preview', true) + sendUserToast(`Failed to load offboard preview: ${apiErrorMessage(e)}`, true) onClose() } finally { loading = false @@ -128,7 +128,7 @@ onComplete() } } catch (e) { - sendUserToast(`Offboarding failed: ${e}`, true) + sendUserToast(`Offboarding failed: ${apiErrorMessage(e)}`, true) } finally { submitting = false } diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index b6e0134eff..1760b104cc 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -758,7 +758,10 @@ policy, custom_path: customPath, labels - } + }, + aiReview: $workspaceStore + ? { workspaceId: $workspaceStore, kind: 'app', path: savedApp.path } + : undefined }, opening ) @@ -904,6 +907,9 @@ custom_path: customPath, labels }, + aiReview: $workspaceStore + ? { workspaceId: $workspaceStore, kind: 'app', path: savedApp.path } + : undefined, button: { text: 'Looks good, deploy', onClick: () => { diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 6118f6626d..9037aec2d4 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -122,10 +122,6 @@ ? 'guest' : 'publisher' ) - // Undefined until loaded. An app can be set to `guest` while the workspace has - // guests off, in which case the mode is stored but inert -- say so rather than - // letting the publisher believe the app is open. - let guestAccessEnabled: boolean | undefined = $state(undefined) let guestUsage: GuestUsage | undefined = $state(undefined) // Whether the deployment can have guests at all; off, the mode is not on offer. The // backend decides; the hostname stands in until it has answered, the shared cloud @@ -136,9 +132,6 @@ const ws = opWs if (ws === undefined) return untrack(() => { - WorkspaceService.getPublicSettings({ workspace: ws }) - .then((s) => (guestAccessEnabled = s.guest_access_enabled)) - .catch(() => (guestAccessEnabled = undefined)) WorkspaceService.getGuestUsage({ workspace: ws }) .then((u) => (guestUsage = u)) .catch(() => (guestUsage = undefined)) @@ -553,14 +546,6 @@ {#if !guestsAvailable} Guests are not available on Windmill Cloud, so this app still admits members only. They require a self-hosted instance or a dedicated Windmill Cloud deployment. - {:else if guestUsage && !guestUsage.instance_enabled} - A superadmin has turned guests off for this instance, so this app still admits members - only. - {:else if guestAccessEnabled === undefined} - Checking whether this workspace allows guests… - {:else if guestAccessEnabled === false} - Guests are turned off for this workspace, so this app still admits members only. A - workspace admin can turn them on in the workspace settings. {:else} Anyone your identity provider authenticates can open this app without a Windmill account. They join no workspace. Members of this workspace can open it too. @@ -614,7 +599,7 @@ </div> </div> - {#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable} + {#if embedMode && policy.execution_mode == 'guest' && guestJwtBase && guestsAvailable} <div class="mt-6 flex flex-col gap-2"> <div class="text-xs font-semibold text-emphasis"> Embed for your own authenticated users (guest JWT) diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index d86713253a..56563a957c 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -117,6 +117,9 @@ headers }) if (!res.ok) { + // Minted for the deployed app under the caller's RLS: a 404 is "nothing deployed here" or + // "not visible to this member", the same reading as `getAppLiteByPath`'s 404 below. + if (res.status == 404) onLoadState?.('not_found') const err: any = new Error('Failed to fetch embed token') err.status = res.status throw err diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index fc9911edf2..b6fea08296 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlow.json +++ b/frontend/src/lib/components/copilot/chat/flow/openFlow.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"version":"1.846.1","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution — modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind"}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"requires_approval":{"type":"boolean","description":"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"},{"$ref":"#/components/schemas/QuestionToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"QuestionToolValue":{"type":"object","description":"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.","properties":{"tool_type":{"type":"string","enum":["question"]},"timeout":{"type":"integer","description":"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800."}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["approval"]},"call_id":{"type":"string"},"calls":{"type":"array","items":{"type":"object","properties":{"function_name":{"type":"string"},"arguments":{}},"required":["function_name","arguments"]}},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","calls","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["question"]},"call_id":{"type":"string"},"question":{"type":"string"},"choices":{"type":"array","items":{"type":"string"},"description":"answers the model proposes; a free-text answer is always possible"},"multi_select":{"type":"boolean","description":"whether several choices can be picked together"},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","question","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file +{"openapi":"3.0.3","info":{"version":"1.846.1","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution — modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'"}},"required":["expr"]},"sleep":{"description":"Delay after this step completes, before the next step starts (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind"}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type"}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"requires_approval":{"type":"boolean","description":"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"},{"$ref":"#/components/schemas/QuestionToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"QuestionToolValue":{"type":"object","description":"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.","properties":{"tool_type":{"type":"string","enum":["question"]},"timeout":{"type":"integer","description":"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800."}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["approval"]},"call_id":{"type":"string"},"calls":{"type":"array","items":{"type":"object","properties":{"function_name":{"type":"string"},"arguments":{}},"required":["function_name","arguments"]}},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","calls","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["question"]},"call_id":{"type":"string"},"question":{"type":"string"},"choices":{"type":"array","items":{"type":"string"},"description":"answers the model proposes; a free-text answer is always possible"},"multi_select":{"type":"boolean","description":"whether several choices can be picked together"},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","question","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index ff686b6de7..66d105a216 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -260,10 +260,9 @@ vi.mock('$lib/gen', async () => { WorkerService: wrapService(actual.WorkerService, { listWorkers: vi.fn(async () => []) }), - // Guests off by default, as a fresh workspace has them. + // Guests unavailable by default, as on the shared cloud. WorkspaceService: wrapService(actual.WorkspaceService, { - getGuestUsage: vi.fn(async () => ({ available: false, instance_enabled: false })), - getPublicSettings: vi.fn(async () => ({ guest_access_enabled: false })) + getGuestUsage: vi.fn(async () => ({ available: false })) }), FolderService: wrapService(actual.FolderService, { createFolder: vi.fn(async () => 'created') @@ -3933,11 +3932,7 @@ describe('global AI tools', () => { } vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({ - available: true, - instance_enabled: true - } as any) - vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({ - guest_access_enabled: true + available: true } as any) expect(await readMode(guestApp)).toBe('guest') @@ -3951,19 +3946,8 @@ describe('global AI tools', () => { }) ).toBe('anonymous') - // The same app, with each switch crossed in turn: either one alone admits nobody, so - // reporting the mode bare would name an exposure the server refuses. Both off needs - // no case of its own — whichever half of the check were dropped, one of these two - // still catches it. - vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({ - guest_access_enabled: true - } as any) - expect(await readMode(guestApp)).toContain('inert') - - vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({ - available: true, - instance_enabled: true - } as any) + // The same app where the deployment admits no guest: reporting the mode bare would + // name an exposure the server refuses. expect(await readMode(guestApp)).toContain('inert') }) @@ -4734,36 +4718,19 @@ describe('global AI tools', () => { // anyone outside the deployers group — so the note must not name an identity. expect(open.message).not.toContain('u/alice') - // Guest is a real widening only where the deployment, the instance and the - // workspace all admit guests; stored below that, it is inert. + // Guest is a real widening only where the deployment admits guests; stored on one + // that does not, it is inert and the note would announce an exposure that does not + // exist. vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({ - available: true, - instance_enabled: true - } as any) - vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({ - guest_access_enabled: true + available: true } as any) const guestOn = await deployApp('f/apps/guest', { execution_mode: 'guest' }) expect(guestOn.success).toBe(true) expect(guestOn.message).toContain('identity provider authenticates') - // Each switch crossed in turn, because either one alone admits nobody and the note - // would then announce an exposure that does not exist. Both off needs no case of its - // own: whichever half of the check were dropped, one of these two still catches it. - vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({ - guest_access_enabled: true - } as any) - const instanceOff = await deployApp('f/apps/guest_inst_off', { execution_mode: 'guest' }) - expect(instanceOff.success).toBe(true) - expect(instanceOff.message).not.toContain('identity provider') - - vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({ - available: true, - instance_enabled: true - } as any) - const workspaceOff = await deployApp('f/apps/guest_ws_off', { execution_mode: 'guest' }) - expect(workspaceOff.success).toBe(true) - expect(workspaceOff.message).not.toContain('identity provider') + const unavailable = await deployApp('f/apps/guest_unavailable', { execution_mode: 'guest' }) + expect(unavailable.success).toBe(true) + expect(unavailable.message).not.toContain('identity provider') }) it('forwards preserve_on_behalf_of when the deployed policy carries an on_behalf_of', async () => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 50fde9e552..bc57802dbf 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -2519,18 +2519,15 @@ const triggerServices: Record<TriggerKind, TriggerService> = { } /** Whether the server would admit a guest here: the deployment has to support guests at - * all, and the instance and workspace switches both have to be on. `guest` is stored on - * an app even when none of that holds, so the mode alone never settles who can open it. - * `undefined` when a switch read fails — neither proven live nor proven inert. */ + * all. `guest` is stored on an app even where it does not, so the mode alone never + * settles who can open it. `undefined` when the read fails — neither proven live nor + * proven inert. */ async function guestAccessIsLive(workspace: string): Promise<boolean | undefined> { - const [usage, settings] = await Promise.all([ - WorkspaceService.getGuestUsage({ workspace }).catch(() => undefined), - WorkspaceService.getPublicSettings({ workspace }).catch(() => undefined) - ]) - if (usage === undefined || settings === undefined) { + const usage = await WorkspaceService.getGuestUsage({ workspace }).catch(() => undefined) + if (usage === undefined) { return undefined } - return !!(usage.available && usage.instance_enabled && settings.guest_access_enabled) + return !!usage.available } /** Who may open an app, from the mode stored on it. An app sits in `guest` mode whether @@ -2544,7 +2541,7 @@ async function describeAppExposure( if (mode !== 'guest' || (await guestAccessIsLive(workspace)) !== false) { return mode } - return 'guest, but inert: the instance or workspace admits no guest, so this app admits members only' + return 'guest, but inert: this deployment admits no guest, so this app admits members only' } async function readWorkspaceItem( @@ -6040,7 +6037,9 @@ function createVariableToDraftState( // `null` clears and omitted keeps the base's. The draft stores a cleared expiry as // `undefined`, since a stored draft goes through a JSON round-trip. value_valid_until: - args.value_valid_until === null ? undefined : (args.value_valid_until ?? base?.value_valid_until) + args.value_valid_until === null + ? undefined + : (args.value_valid_until ?? base?.value_valid_until) } } diff --git a/frontend/src/lib/components/copilot/lib.toolCalls.test.ts b/frontend/src/lib/components/copilot/lib.toolCalls.test.ts index 6966e018d1..b5bd24d6fa 100644 --- a/frontend/src/lib/components/copilot/lib.toolCalls.test.ts +++ b/frontend/src/lib/components/copilot/lib.toolCalls.test.ts @@ -310,6 +310,9 @@ describe('output token limit', () => { // vendor's, while a Custom AI proxy in front of a hosted model keeps its row. expect(getModelMaxTokens('customai', 'deepseek-v4-pro', 'high')).toBe(8192) expect(getModelMaxTokens('customai', 'gpt-5')).toBe(128000) + // GLM's own API (z.ai) is only reachable through Custom AI. + expect(getModelMaxTokens('customai', 'glm-5.1')).toBe(32768) + expect(getModelMaxTokens('customai', 'glm-4.5v')).toBe(8192) }) it('sizes the budget from the effort the same request carries', async () => { diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index f9653bff25..22797099d2 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -152,7 +152,13 @@ const MODEL_MAX_OUTPUT_TOKENS: [name: string, maxOutputTokens: number][] = [ ['o4-mini', 100_000], // Google ['gemini-3', 64_000], - ['gemini-2.5', 64_000] + ['gemini-2.5', 64_000], + // Z.ai — open-weight, but listed here because z.ai's own API is reached only + // through Custom AI. GLM thinks by default and counts it toward max_tokens. No + // family row: older GLMs cap output lower (4.5V at 16K, GLM-4 at 4K). + ['glm-5', 32_768], + ['glm-4.7', 32_768], + ['glm-4.6', 32_768] ] // Open-weight models, which a self-hosted server (Custom AI) caps at whatever context diff --git a/frontend/src/lib/components/deploymentRequest/AiReview.svelte b/frontend/src/lib/components/deploymentRequest/AiReview.svelte index e36a269c6d..0af4488718 100644 --- a/frontend/src/lib/components/deploymentRequest/AiReview.svelte +++ b/frontend/src/lib/components/deploymentRequest/AiReview.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import { Alert, Badge, Button } from '$lib/components/common' - import type { AIProviderModel, WorkspaceItemDiff } from '$lib/gen' + import type { AIProviderModel } from '$lib/gen' import { copilotInfo, copilotSessionModel } from '$lib/aiStore' import Select from '$lib/components/select/Select.svelte' import { sendUserToast } from '$lib/toast' @@ -12,19 +12,38 @@ import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle' import { buildCompareUrl } from '$lib/components/copilot/chat/global/pageNavigation' import type { SessionTarget } from '$lib/components/sessions/sessionState.svelte' - import { reviewDeployment, type AiReviewFinding, type AiReviewResult } from './aiReview' + import { + instructionsWorkspace, + reviewChanges, + scopeItems, + scopeWorkspace, + type AiReviewFinding, + type AiReviewResult, + type ReviewScope + } from './aiReview' interface Props { - diffs: WorkspaceItemDiff[] - forkWorkspaceId: string - parentWorkspaceId: string - /** Where the review runs: the items selected for deploy, the request form, or an open request. */ - source: 'selection' | 'request_form' | 'open_request' + scope: ReviewScope + /** + * Where the review runs: the items selected for deploy, the request form, an open request, + * the drafts selected on the drafts page, one draft's diff, or a session's edits. + */ + source: + | 'selection' + | 'request_form' + | 'open_request' + | 'drafts' + | 'draft_diff' + | 'session_edits' /** Absent before the request exists: findings are then only shown, for the author to act on. */ onPost?: (finding: AiReviewFinding) => Promise<void> } - let { diffs, forkWorkspaceId, parentWorkspaceId, source, onPost }: Props = $props() + let { scope, source, onPost }: Props = $props() + + let items = $derived(scopeItems(scope)) + let workspaceId = $derived(scopeWorkspace(scope)) + let instructionsWorkspaceId = $derived(instructionsWorkspace(scope)) let running = $state(false) let result = $state<AiReviewResult | undefined>(undefined) @@ -32,15 +51,15 @@ let postingId = $state<string | undefined>(undefined) let abortController: AbortController | undefined - // `diffs` can change under a finished or running review (a selection is edited, the + // The items can change under a finished or running review (a selection is edited, the // comparison refreshes), so the result is tied to the items it actually covered. - const itemsKey = (ds: WorkspaceItemDiff[]) => + const itemsKey = (ds: { kind: string; path: string }[]) => ds .map((d) => `${d.kind}:${d.path}`) .sort() .join('\n') let reviewed = $state<{ key: string; count: number } | undefined>(undefined) - let stale = $derived(reviewed != undefined && reviewed.key !== itemsKey(diffs)) + let stale = $derived(reviewed != undefined && reviewed.key !== itemsKey(items)) const modelKey = (m: AIProviderModel) => `${m.provider}:${m.model}` // Local to this review: picking a model here must not change the chat's own selection, @@ -58,10 +77,10 @@ running = true error = undefined result = undefined - reviewed = { key: itemsKey(diffs), count: diffs.length } + reviewed = { key: itemsKey(items), count: items.length } logFeatureUsage('deployment_request', 'ai_review', { key: source }) try { - const review = await reviewDeployment(diffs, forkWorkspaceId, parentWorkspaceId, controller, { + const review = await reviewChanges(scope, controller, { model: $copilotInfo.aiModels.find((m) => modelKey(m) === selectedModelKey) }) if (!controller.signal.aborted) result = review @@ -74,6 +93,21 @@ $effect(() => () => abortController?.abort()) + // A review answers for the workspaces it ran in. The same paths in another workspace are + // other items, so a switch drops the result and any review still running, rather than + // leaving it to read as current there. + $effect(() => { + void workspaceId + void instructionsWorkspaceId + return () => { + abortController?.abort() + running = false + result = undefined + error = undefined + reviewed = undefined + } + }) + function stop() { abortController?.abort() running = false @@ -118,16 +152,20 @@ const SESSION_TARGET_KINDS: string[] = ['script', 'flow', 'raw_app'] - // The session is pinned to the fork: the fixes belong in the workspace under review, and a - // session left to pick its own would start in whatever workspace the user last used. + // The session is pinned to the reviewed workspace: the fixes belong there, and a session left + // to pick its own would start in whatever workspace the user last used. function fixSource(findings: AiReviewFinding[]): OpenInSessionSource { const lines = findings.map( (f) => `- [${f.severity}] ${f.kind && f.path ? `${f.kind} ${f.path}: ` : ''}${f.comment}` ) const one = findings.length === 1 ? findings[0] : undefined const common = { - workspaceId: forkWorkspaceId, - seedPrompt: `An AI review of this workspace's changes, before deploying them to ${parentWorkspaceId}, found ${one ? 'this issue' : 'these issues'}. Fix ${one ? 'it' : 'them'} here:\n\n${lines.join('\n')}`, + workspaceId, + seedPrompt: `${ + scope.kind === 'deployment' + ? `An AI review of this workspace's changes, before deploying them to ${scope.parentWorkspaceId}` + : "An AI review of this workspace's drafts, before deploying them" + }, found ${one ? 'this issue' : 'these issues'}. Fix ${one ? 'it' : 'them'} here:\n\n${lines.join('\n')}`, autoSend: true, beforeOpen: () => logFeatureUsage('deployment_request', 'ai_fix_in_session', { @@ -139,7 +177,11 @@ } return { ...common, - page: () => buildCompareUrl({ workspace_id: forkWorkspaceId, mode: 'fork' }) + page: () => + buildCompareUrl({ + workspace_id: workspaceId, + mode: scope.kind === 'deployment' ? 'fork' : 'draft' + }) } } </script> @@ -159,7 +201,7 @@ variant="default" unifiedSize="sm" startIcon={{ icon: Sparkles }} - disabled={diffs.length === 0} + disabled={items.length === 0} onclick={run} > {result ? 'Review again with AI' : 'Review with AI'} @@ -173,9 +215,11 @@ /> </div> {/if} - {#if diffs.length === 0} + {#if items.length === 0} <span class="text-xs text-secondary"> - {source === 'selection' ? 'Select items to review.' : 'No changes to review.'} + {source === 'selection' || source === 'drafts' + ? 'Select items to review.' + : 'No changes to review.'} </span> {/if} {/if} @@ -199,15 +243,15 @@ {#if result.instructions.length > 0} Applied {result.instructions.length} review instruction{result.instructions.length === 1 ? '' - : 's'} from {parentWorkspaceId}: {result.instructions.join(', ')}. + : 's'} from {instructionsWorkspaceId}: {result.instructions.join(', ')}. {:else} - No review instructions from {parentWorkspaceId} applied. + No review instructions from {instructionsWorkspaceId} applied. {/if} - Only the ai_review_instruction resources you can read in {parentWorkspaceId} are used. + Only the ai_review_instruction resources you can read in {instructionsWorkspaceId} are used. </div> {#if result.instructionsUnavailable} <Alert type="warning" title="Review instructions unavailable" size="xs"> - Could not list the review instructions of {parentWorkspaceId}, so none were applied. + Could not list the review instructions of {instructionsWorkspaceId}, so none were applied. </Alert> {/if} {#if result.skippedInstructions.length > 0} @@ -231,7 +275,7 @@ source={fixSource(result.findings)} btnClasses={AIBtnClasses('default')} label="Fix all in AI session" - tooltip="Open an AI session in the fork and fix every issue listed" + tooltip="Open an AI session in {workspaceId} and fix every issue listed" btnProps={{ iconOnly: false, unifiedSize: 'xs', startIcon: { icon: WandSparkles } }} /> {#if onPost} @@ -269,7 +313,7 @@ source={fixSource([f])} btnClasses={AIBtnClasses('default')} label="Fix in AI session" - tooltip="Open an AI session in the fork and fix this issue" + tooltip="Open an AI session in {workspaceId} and fix this issue" btnProps={{ iconOnly: false, unifiedSize: 'xs', startIcon: { icon: WandSparkles } }} /> {#if onPost} diff --git a/frontend/src/lib/components/deploymentRequest/DeploymentRequestPanel.svelte b/frontend/src/lib/components/deploymentRequest/DeploymentRequestPanel.svelte index 35196eeedb..90d8ff4739 100644 --- a/frontend/src/lib/components/deploymentRequest/DeploymentRequestPanel.svelte +++ b/frontend/src/lib/components/deploymentRequest/DeploymentRequestPanel.svelte @@ -261,7 +261,10 @@ </div> {#if $copilotInfo.enabled} <div class="border-t pt-2"> - <AiReview {diffs} {forkWorkspaceId} {parentWorkspaceId} source="request_form" /> + <AiReview + scope={{ kind: 'deployment', diffs, forkWorkspaceId, parentWorkspaceId }} + source="request_form" + /> </div> {/if} </div> @@ -294,9 +297,7 @@ <div class="border rounded-md bg-surface p-3 flex flex-col gap-3"> {#if $copilotInfo.enabled} <AiReview - {diffs} - {forkWorkspaceId} - {parentWorkspaceId} + scope={{ kind: 'deployment', diffs, forkWorkspaceId, parentWorkspaceId }} source="open_request" onPost={postAiFinding} /> diff --git a/frontend/src/lib/components/deploymentRequest/aiReview.test.ts b/frontend/src/lib/components/deploymentRequest/aiReview.test.ts index 6d5d8e55fe..559164a5a3 100644 --- a/frontend/src/lib/components/deploymentRequest/aiReview.test.ts +++ b/frontend/src/lib/components/deploymentRequest/aiReview.test.ts @@ -49,7 +49,15 @@ vi.mock('$lib/gen', async (importOriginal) => ({ } })) -const { reviewDeployment } = await import('./aiReview') +const { reviewChanges } = await import('./aiReview') + +const reviewDeployment = ( + diffs: WorkspaceItemDiff[], + forkWorkspaceId: string, + parentWorkspaceId: string, + abortController: AbortController +) => + reviewChanges({ kind: 'deployment', diffs, forkWorkspaceId, parentWorkspaceId }, abortController) function diff(path: string): WorkspaceItemDiff { return { @@ -107,3 +115,39 @@ describe('reviewDeployment', () => { expect(result.instructions).toEqual([]) }) }) + +describe('drafts review', () => { + it("reviews drafts as deployed against draft, under their own workspace's instructions", async () => { + const result = await reviewChanges( + { + kind: 'drafts', + workspaceId: 'parent', + items: [ + { + kind: 'script', + path: 'f/a/b', + change: 'modified', + load: async () => ({ before: 'old body', after: 'new body' }) + }, + { + kind: 'flow', + path: 'f/a/c', + change: 'added', + load: async () => { + throw new Error('404') + } + } + ] + }, + new AbortController() + ) + expect(h.prompt).toContain( + '### script f/a/b (modified)\n#### Deployed version\nold body\n#### Draft version\nnew body' + ) + expect(result.unreadable).toEqual(['flow:f/a/c']) + expect(result.instructions).toEqual(['f/a/REVIEW']) + expect(h.listed).toEqual(['parent']) + expect(h.workspace).toBe('parent') + expect(h.system).toContain('You review drafts in a Windmill workspace') + }) +}) diff --git a/frontend/src/lib/components/deploymentRequest/aiReview.ts b/frontend/src/lib/components/deploymentRequest/aiReview.ts index e05da4d7e4..9a906b052c 100644 --- a/frontend/src/lib/components/deploymentRequest/aiReview.ts +++ b/frontend/src/lib/components/deploymentRequest/aiReview.ts @@ -17,7 +17,8 @@ import { reviewSystemPrompt, VALUE_WITHHELD_KINDS, type AiReviewFinding, - type FolderInstruction + type FolderInstruction, + type ReviewSubject } from './aiReviewCore' export { @@ -41,32 +42,88 @@ export type AiReviewResult = { instructionsUnavailable: boolean } +/** One changed item to review, its two sides read only once the review gets to it. */ +export type ReviewItem = { + kind: string + path: string + change: 'added' | 'deleted' | 'modified' + /** Resolves `undefined`, or throws, when the contents could not be read. */ + load: () => Promise<{ before: unknown; after: unknown } | undefined> +} + +/** + * What a review covers: the changes a fork carries into its parent, or drafts not yet deployed + * in their own workspace. + */ +export type ReviewScope = + | { + kind: 'deployment' + diffs: WorkspaceItemDiff[] + forkWorkspaceId: string + parentWorkspaceId: string + } + | { kind: 'drafts'; workspaceId: string; items: ReviewItem[] } + +/** The workspace whose items change: fixes belong there, and the AI call is bound to it. */ +export function scopeWorkspace(scope: ReviewScope): string { + return scope.kind === 'deployment' ? scope.forkWorkspaceId : scope.workspaceId +} + +/** + * The workspace review instructions are read from. A fork's changes are reviewed under its + * parent's, never the fork's own: the fork is the author's side, and its copy of an instruction + * is itself one of the changes under review. Drafts are reviewed under their workspace's + * deployed instructions, for the same reason a draft of one does not apply. + */ +export function instructionsWorkspace(scope: ReviewScope): string { + return scope.kind === 'deployment' ? scope.parentWorkspaceId : scope.workspaceId +} + +export function scopeItems(scope: ReviewScope): ReviewItem[] { + if (scope.kind === 'drafts') return scope.items + const { forkWorkspaceId, parentWorkspaceId } = scope + return scope.diffs.map((diff) => { + const change = changeType(diff) + return { + kind: diff.kind, + path: diff.path, + change, + load: async () => { + const kind = diff.kind as Kind + const [before, after] = await Promise.all([ + change === 'added' ? undefined : getItemValue(kind, diff.path, parentWorkspaceId), + change === 'deleted' ? undefined : getItemValue(kind, diff.path, forkWorkspaceId) + ]) + // `getItemValue` answers `{}` when a read fails, which would pass as an emptied item. + return isEmptyRead(before) || isEmptyRead(after) ? undefined : { before, after } + } + } + }) +} + const FETCH_BATCH = 6 -// Read from the parent, never the fork: the fork is the author's side, and its copy of an -// instruction is itself one of the changes under review. A failed listing reviews without -// instructions rather than failing the review, and is reported as `unavailable`. +// A failed listing reviews without instructions rather than failing the review, and is reported +// as `unavailable`. async function reviewInstructions( - diffs: WorkspaceItemDiff[], - parentWorkspaceId: string + subject: ReviewSubject, + paths: string[], + workspaceId: string ): Promise<{ text: string; applied: string[]; skipped: string[]; unavailable: boolean }> { let listed: FolderInstruction[] try { - listed = await listFolderInstructions(parentWorkspaceId, REVIEW_INSTRUCTIONS_RESOURCE_TYPE) + listed = await listFolderInstructions(workspaceId, REVIEW_INSTRUCTIONS_RESOURCE_TYPE) } catch (e) { console.error('Failed to list review instructions', e) return { text: '', applied: [], skipped: [], unavailable: true } } - const covering = instructionsCovering( - listed, - diffs.map((d) => d.path) - ) + const covering = instructionsCovering(listed, paths) const reads = await Promise.all( covering.map(async (instruction) => { try { return { instruction, - body: await readFolderInstructionBody(parentWorkspaceId, instruction.path) + body: await readFolderInstructionBody(workspaceId, instruction.path) } } catch (e) { console.error(`Failed to read review instructions ${instruction.path}`, e) @@ -78,7 +135,7 @@ async function reviewInstructions( (r): r is { instruction: FolderInstruction; body: string } => r.body !== undefined ) return { - text: reviewInstructionsSection('deployment_request', blocks), + text: reviewInstructionsSection(subject, blocks), applied: blocks.map((b) => b.instruction.path), skipped: reads.filter((r) => r.body === undefined).map((r) => r.instruction.path), unavailable: false @@ -91,27 +148,30 @@ function changeType(diff: WorkspaceItemDiff): 'added' | 'deleted' | 'modified' { return 'modified' } +const SIDES: Record<ReviewScope['kind'], [string, string]> = { + deployment: ['Parent version', 'Fork version'], + drafts: ['Deployed version', 'Draft version'] +} + async function itemSection( - diff: WorkspaceItemDiff, - forkWorkspaceId: string, - parentWorkspaceId: string + item: ReviewItem, + sides: [string, string] ): Promise<{ text: string; unreadable: boolean }> { - const change = changeType(diff) - const header = `### ${diff.kind} ${diff.path} (${change})` - if (VALUE_WITHHELD_KINDS.has(diff.kind)) { + const header = `### ${item.kind} ${item.path} (${item.change})` + if (VALUE_WITHHELD_KINDS.has(item.kind)) { return { text: `${header}\nValue withheld from review.`, unreadable: false } } - const kind = diff.kind as Kind - const [parent, fork] = await Promise.all([ - change === 'added' ? undefined : getItemValue(kind, diff.path, parentWorkspaceId), - change === 'deleted' ? undefined : getItemValue(kind, diff.path, forkWorkspaceId) - ]) - // `getItemValue` answers `{}` when a read fails, which would pass as an emptied item. - if (isEmptyRead(parent) || isEmptyRead(fork)) { + const values = await item.load().catch((e) => { + console.error(`Failed to read ${item.kind} ${item.path} for review`, e) + return undefined + }) + if (!values) { return { text: `${header}\nContents could not be read.`, unreadable: true } } + const before = item.change === 'added' ? undefined : values.before + const after = item.change === 'deleted' ? undefined : values.after return { - text: `${header}\n#### Parent version\n${renderItemValue(parent)}\n#### Fork version\n${renderItemValue(fork)}`, + text: `${header}\n#### ${sides[0]}\n${renderItemValue(before)}\n#### ${sides[1]}\n${renderItemValue(after)}`, unreadable: false } } @@ -120,30 +180,33 @@ function isEmptyRead(value: unknown): boolean { return value != null && typeof value === 'object' && Object.keys(value as object).length === 0 } -/** Reviews the changes `diffs` carry from the fork into its parent with the workspace's AI model. */ -export async function reviewDeployment( - diffs: WorkspaceItemDiff[], - forkWorkspaceId: string, - parentWorkspaceId: string, +/** Reviews the changes `scope` covers with the workspace's AI model. */ +export async function reviewChanges( + scope: ReviewScope, abortController: AbortController, options?: { /** Defaults to the chat's current model. */ model?: AIProviderModel } ): Promise<AiReviewResult> { - const instructionsPromise = reviewInstructions(diffs, parentWorkspaceId) + const subject: ReviewSubject = scope.kind === 'deployment' ? 'deployment_request' : 'draft' + const workspaceId = scopeWorkspace(scope) + const items = scopeItems(scope) + const instructionsPromise = reviewInstructions( + subject, + items.map((i) => i.path), + instructionsWorkspace(scope) + ) const budget = new ReviewBudget() const unreadable: string[] = [] - // Fetched a batch at a time so a large fork stops reading items once the budget is spent. - for (let i = 0; i < diffs.length; i += FETCH_BATCH) { - const batch = diffs.slice(i, i + FETCH_BATCH) + // Fetched a batch at a time so a large review stops reading items once the budget is spent. + for (let i = 0; i < items.length; i += FETCH_BATCH) { + const batch = items.slice(i, i + FETCH_BATCH) if (budget.spent) { budget.omitted.push(...batch.map((d) => `${d.kind}:${d.path}`)) continue } - const sections = await Promise.all( - batch.map((d) => itemSection(d, forkWorkspaceId, parentWorkspaceId)) - ) + const sections = await Promise.all(batch.map((d) => itemSection(d, SIDES[scope.kind]))) if (abortController.signal.aborted) throw new Error('aborted') sections.forEach(({ text, unreadable: failed }, j) => { const key = `${batch[j].kind}:${batch[j].path}` @@ -156,26 +219,28 @@ export async function reviewDeployment( if (abortController.signal.aborted) throw new Error('aborted') const answer = await getNonStreamingCompletion( [ - { role: 'system', content: reviewSystemPrompt('deployment_request') + instructions.text }, + { role: 'system', content: reviewSystemPrompt(subject) + instructions.text }, { role: 'user', content: budget.userPrompt( - `Fork workspace: ${forkWorkspaceId}\nParent workspace: ${parentWorkspaceId}` + scope.kind === 'deployment' + ? `Fork workspace: ${scope.forkWorkspaceId}\nParent workspace: ${scope.parentWorkspaceId}` + : `Workspace: ${scope.workspaceId}` ) } ], abortController, - // Bound to the fork: without `workspace` the call goes through whichever workspace the - // app has switched to by the time the item reads finish. + // Bound to the reviewed workspace: without `workspace` the call goes through whichever + // workspace the app has switched to by the time the item reads finish. { maxTokensCap: MAX_OUTPUT_TOKENS, forceModelProvider: options?.model, - workspace: forkWorkspaceId + workspace: workspaceId } ) return { ...parseReviewAnswer(answer ?? '', { - items: new Set(diffs.map((d) => `${d.kind}:${d.path}`)) + items: new Set(items.map((d) => `${d.kind}:${d.path}`)) }), omitted: budget.omitted, unreadable, diff --git a/frontend/src/lib/components/deploymentRequest/aiReviewCore.ts b/frontend/src/lib/components/deploymentRequest/aiReviewCore.ts index 0458b5fbff..81328cf236 100644 --- a/frontend/src/lib/components/deploymentRequest/aiReviewCore.ts +++ b/frontend/src/lib/components/deploymentRequest/aiReviewCore.ts @@ -1,5 +1,5 @@ // What the review asks of the model and how its answer is read, for every place it runs: the -// deployment request panel here, and `wmill review` on a git sync pull request, which carries +// deployment request panel and the drafts review here, and `wmill review` on a git sync pull request, which carries // this module through cli/generate-ai-review.ts. Keep it free of `$lib` imports; the // staleness test in cli/test/ai_review_bundle_unit.test.ts fails when the CLI's copy drifts. import { @@ -43,32 +43,47 @@ export const TOTAL_CHAR_BUDGET = 160_000 // Under the Anthropic SDK's non-streaming pre-flight max_tokens limit. export const MAX_OUTPUT_TOKENS = 8_000 -/** What is being reviewed: a fork's deployment request, or a pull request on a git sync repo. */ -export type ReviewSubject = 'deployment_request' | 'pull_request' +/** + * What is being reviewed: a fork's deployment request, a workspace's undeployed drafts, or a + * pull request on a git sync repo. + */ +export type ReviewSubject = 'deployment_request' | 'draft' | 'pull_request' const INTRO: Record<ReviewSubject, string> = { deployment_request: 'You review a Windmill deployment request: a set of changes made in a fork workspace that will be merged into its parent workspace. Each changed item is given with its parent version (what is deployed now) and its fork version (what will be deployed).', + draft: + 'You review drafts in a Windmill workspace: changes saved to items of the workspace but not deployed yet. Each changed item is given with its deployed version (what is deployed now) and its draft version (what will be deployed).', pull_request: "You review a pull request on a git repository that Windmill's git sync keeps in step with a workspace: merging it deploys its changes. Each changed item is given with the files that make it up in the repository, each as a unified diff of the base branch (what is deployed now) against the pull request (what will be deployed). Every diff line starts with its line number: R<n> for line n of the pull request's version of the file, L<n> for line n of the base version, then + for an added line, - for a removed one and a space for unchanged context." } +const ITEM_ANSWER_SHAPE = + '{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}' + const ANSWER_SHAPE: Record<ReviewSubject, string> = { - deployment_request: - '{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}', + deployment_request: ITEM_ANSWER_SHAPE, + draft: ITEM_ANSWER_SHAPE, pull_request: '{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "file": "<the file the issue is in, as given>", "line": "<the R or L line number the issue is on, as given, such as R12, or null for the file as a whole>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}' } const SOURCE: Record<ReviewSubject, string> = { deployment_request: 'The parent workspace', + draft: 'The workspace', pull_request: "The repository's base branch" } +const APPROVED: Record<ReviewSubject, string> = { + deployment_request: 'the merge', + draft: 'the deployment', + pull_request: 'the merge' +} + export function reviewSystemPrompt(subject: ReviewSubject): string { return `${INTRO[subject]} An item can be a script, flow, app, schedule, trigger, folder, resource type, datatable migration, variable or resource. -Report only issues a reviewer should act on before approving the merge: +Report only issues a reviewer should act on before approving ${APPROVED[subject]}: - bugs and logic errors introduced by the change - breaking changes: removed or renamed inputs, changed input types, changed outputs that callers may rely on - risky operational changes: schedules retimed, triggers pointed at a different script or flow, folder owners or permissions, app policies, destructive migrations diff --git a/frontend/src/lib/components/deploymentRequest/draftReviewItem.ts b/frontend/src/lib/components/deploymentRequest/draftReviewItem.ts new file mode 100644 index 0000000000..777f574624 --- /dev/null +++ b/frontend/src/lib/components/deploymentRequest/draftReviewItem.ts @@ -0,0 +1,21 @@ +import { getDraftDiffValues } from '$lib/utils_draft_deploy' +import type { UserDraftItemKind } from '$lib/gen' +import type { ReviewItem } from './aiReview' + +/** A draft to review against its deployed version, read the way its diff is shown. */ +export function draftReviewItem( + kind: UserDraftItemKind, + path: string, + workspaceId: string, + draftOnly: boolean +): ReviewItem { + return { + kind, + path, + change: draftOnly ? 'added' : 'modified', + load: async () => { + const { deployed, draft } = await getDraftDiffValues(kind, path, workspaceId, draftOnly) + return { before: deployed, after: draft } + } + } +} diff --git a/frontend/src/lib/components/details/BuildChatAppCard.svelte b/frontend/src/lib/components/details/BuildChatAppCard.svelte new file mode 100644 index 0000000000..7c1a52ef32 --- /dev/null +++ b/frontend/src/lib/components/details/BuildChatAppCard.svelte @@ -0,0 +1,80 @@ +<script lang="ts"> + import { Button } from '$lib/components/common' + import { goto } from '$lib/navigation' + import { LayoutDashboard } from 'lucide-svelte' + import Popover from '$lib/components/meltComponents/Popover.svelte' + import { useEditRights } from '$lib/operatorWriteRights' + import { createChatRawApp } from './createRawAppFromScript' + + /** At the foot of the conversations sidebar of an agent's or a chat-mode flow's page: builds + * a chat app over it. Shown only to those who may author raw apps. */ + let { + kind, + path, + workspace, + expanded, + summary = undefined, + schema = undefined, + onNavigate = (url) => goto(url) + }: { + kind: 'agent' | 'flow' + path: string + workspace: string + /** Whether the sidebar is open: a card when it is, an icon button on its collapsed strip. */ + expanded: boolean + summary?: string + schema?: Record<string, any> + onNavigate?: (url: string) => void | Promise<void> + } = $props() + + const editRights = useEditRights(() => workspace) + let canBuild = $derived(editRights.roleCanAuthor('raw_app')) + + async function buildApp() { + const app = createChatRawApp({ path, runType: kind, summary, schema }) + // The raw app editor reads the payload from sessionStorage on mount. + sessionStorage.setItem('rawAppImport', JSON.stringify(app)) + await onNavigate('/apps_raw/add') + } +</script> + +{#snippet card(classes: string)} + <div class="flex flex-col gap-2 p-3 {classes}"> + <span class="text-sm font-semibold text-emphasis">Share as an app</span> + <span class="text-xs text-secondary"> + Turn this {kind} into a chat app you can share. Adapt its design to fit your needs. + </span> + <Button + unifiedSize="sm" + variant="default" + startIcon={{ icon: LayoutDashboard, classes: 'text-orange-500' }} + onClick={buildApp} + > + Build chat app + </Button> + </div> +{/snippet} + +{#if canBuild} + {#if expanded} + {@render card('rounded-md border border-light bg-surface-tertiary')} + {:else} + <!-- Collapsed, the icon alone does not say what it builds: it opens the card. --> + <Popover placement="right-end" closeButton={false}> + {#snippet trigger()} + <Button + nonCaptureEvent + unifiedSize="md" + variant="subtle" + iconOnly + startIcon={{ icon: LayoutDashboard, classes: 'ml-[2px] text-orange-500' }} + title="Share as an app" + btnClasses="w-fit px-2" + /> + {/snippet} + {#snippet content()} + {@render card('w-60')} + {/snippet} + </Popover> + {/if} +{/if} diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 862435fb57..b57b229093 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -55,7 +55,11 @@ import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte' import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte' - import { createRawAppFromFlow } from '$lib/components/details/createRawAppFromScript' + import { + createChatRawApp, + createRawAppFromFlow + } from '$lib/components/details/createRawAppFromScript' + import BuildChatAppCard from '$lib/components/details/BuildChatAppCard.svelte' import TimeAgo from '$lib/components/TimeAgo.svelte' import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte' import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' @@ -466,19 +470,28 @@ } }) - if (!flow || !canAuthorFlow || !can_write) { - return buttons - } - - // Build app starts from the full template, not the builder one a compose-only role deploys. - if (caps.apps === 'full') { + // A chat-mode flow gets the chat template rather than a form over its inputs. It holds no + // inline code, so authoring raw apps is enough, as on the sidebar card. The form app starts + // from the full template, not the builder one a compose-only role deploys. + const chat = !!flow?.value?.chat_input_enabled + const canBuildApp = chat + ? editRights.roleCanAuthor('raw_app') + : canAuthorFlow && can_write && caps.apps === 'full' + if (flow && canBuildApp) { buttons.push({ - label: 'Build app', + label: chat ? 'Build chat app' : 'Build app', placement: 'menu', alwaysPlaced: true, buttonProps: { onClick: async () => { - const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema) + const app = chat + ? createChatRawApp({ + path: flow.path, + runType: 'flow', + summary: flow.summary, + schema: flow.schema + }) + : createRawAppFromFlow(flow.path, flow.summary, flow.schema) // The raw app editor reads the payload from sessionStorage on mount. sessionStorage.setItem('rawAppImport', JSON.stringify(app)) await onNavigate('/apps_raw/add') @@ -491,6 +504,10 @@ }) } + if (!flow || !canAuthorFlow || !can_write) { + return buttons + } + buttons.push({ label: 'Edit', buttonProps: { @@ -857,7 +874,21 @@ flowModules={flow?.value?.modules} wideLayout frame="none" - /> + > + {#snippet sidebarFooter({ expanded })} + {#if workspace && flow} + <BuildChatAppCard + kind="flow" + path={flow.path} + {workspace} + {expanded} + summary={flow.summary} + schema={flow.schema} + {onNavigate} + /> + {/if} + {/snippet} + </FlowChat> {:else} {@const hasSchema = flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0} @@ -1016,6 +1047,7 @@ isDeployed={true} noCapture={true} isEditor={false} + chatMode={chatInputEnabled} /> {/if} {/snippet} diff --git a/frontend/src/lib/components/details/createRawAppFromScript.test.ts b/frontend/src/lib/components/details/createRawAppFromScript.test.ts index a8f9a9e0e2..5d3b08641b 100644 --- a/frontend/src/lib/components/details/createRawAppFromScript.test.ts +++ b/frontend/src/lib/components/details/createRawAppFromScript.test.ts @@ -2,11 +2,28 @@ import { describe, expect, it } from 'vitest' import { RESERVED_LOCALS, + createRawAppFromAgent, createRawAppFromFlow, createRawAppFromScript } from './createRawAppFromScript' describe('createRawAppFromScript', () => { + it('builds an agent form over its message that shows only the answer', () => { + const app = createRawAppFromAgent('u/dev/helper') + const runnable = app.value.runnables.helper as { + runType: string + schema: { properties: object } + } + expect(runnable.runType).toBe('agent') + expect(Object.keys(runnable.schema.properties)).toEqual(['user_message']) + const appTsx = app.value.files['/App.tsx'] + expect(appTsx).toContain('setResult((await backend.helper({') + expect(appTsx).toContain('))?.output)') + expect(appTsx).toContain( + "typeof result === 'string' ? result : JSON.stringify(result, null, 2)" + ) + }) + it('builds a path runnable and a form calling it', () => { const app = createRawAppFromScript('u/dev/greet_user', 'Greet a user', { type: 'object', diff --git a/frontend/src/lib/components/details/createRawAppFromScript.ts b/frontend/src/lib/components/details/createRawAppFromScript.ts index 5ad92426af..a8dd68fd45 100644 --- a/frontend/src/lib/components/details/createRawAppFromScript.ts +++ b/frontend/src/lib/components/details/createRawAppFromScript.ts @@ -1,10 +1,13 @@ import { forbiddenIds } from '$lib/components/flows/idUtils' -import { react19Template } from '$lib/components/raw_apps/templates' +import { react19Template, themeCss } from '$lib/components/raw_apps/templates' +import { AGENT_CHAT_SCHEMA } from '$lib/components/flows/conversations/agentEditorChat' /** Shape the `/apps_raw/add` import handoff consumes (`$importStore` / * `sessionStorage.rawAppImport`). */ export type RawAppImport = { summary: string + /** Open it in an AI session where AI is used through sessions, rather than the bare editor. */ + openInAiSession?: boolean value: { files: Record<string, string> runnables: Record<string, any> @@ -415,8 +418,10 @@ function generateAppTsx(opts: { fields: Field[] title: string subtitle: string + /** An agent's run also returns its usage, every message and the raw stream: keep the answer. */ + outputOnly?: boolean }): string { - const { runnableId, fields, title, subtitle } = opts + const { runnableId, fields, title, subtitle, outputOnly } = opts const states = fields .map((f) => ` const [${f.local}, ${f.setter}] = useState(${f.init})`) .join('\n') @@ -457,7 +462,7 @@ ${states}${states === '' ? '' : '\n'} const [result, setResult] = useState(undef setRunning(true) setError(undefined) try { - setResult(await backend.${runnableId}(${callArgs})) + setResult(${outputOnly ? `(await backend.${runnableId}(${callArgs}))?.output` : `await backend.${runnableId}(${callArgs})`}) } catch (e) { setResult(undefined) setError(e instanceof Error ? e.message : String(e)) @@ -481,7 +486,7 @@ ${form} <button className="run-button" type="submit" disabled={running}> </button> </form> {error !== undefined && <pre className="app-error">{error}</pre>} - {result !== undefined && <pre className="app-result">{JSON.stringify(result, null, 2)}</pre>} + {result !== undefined && <pre className="app-result">{${outputOnly ? `typeof result === 'string' ? result : ` : ''}JSON.stringify(result, null, 2)}</pre>} </main> ) } @@ -651,7 +656,7 @@ function createRawApp(opts: { path: string summary: string | undefined schema: Record<string, any> | undefined - runType: 'script' | 'flow' + runType: 'script' | 'flow' | 'agent' }): RawAppImport { const { path, summary, schema, runType } = opts const runnableId = runnableIdFromPath(path) @@ -677,6 +682,7 @@ function createRawApp(opts: { return { summary: title, + openInAiSession: true, value: { files: { ...react19Template, @@ -684,7 +690,8 @@ function createRawApp(opts: { runnableId, fields, title, - subtitle: `${runType} · ${path}` + subtitle: `${runType} · ${path}`, + outputOnly: runType === 'agent' }), '/index.css': indexCss }, @@ -718,3 +725,448 @@ export function createRawAppFromFlow( ): RawAppImport { return createRawApp({ path, summary, schema, runType: 'flow' }) } + +/** For an agent without managed memory, which answers each message on its own: a form over the + * message. Attachments are left out, a raw app having no upload for them. */ +export function createRawAppFromAgent(path: string): RawAppImport { + const { user_message } = AGENT_CHAT_SCHEMA.properties + return createRawApp({ + path, + summary: undefined, + schema: { ...AGENT_CHAT_SCHEMA, properties: { user_message } }, + runType: 'agent' + }) +} + +const chatAppTsx = (chatName: string) => `import React, { useEffect, useRef, useState } from 'react' +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' +import * as wmill from './wmill' +import './index.css' + +const CHAT_NAME = ${JSON.stringify(chatName)} +document.title = CHAT_NAME + +export default function App() { + // Each turn runs through the \`chat\` runnable, as this app. Conversations are + // kept in the viewer's browser. + const chat = useWindmillChat({ wmill, runnable: 'chat' }) + const [input, setInput] = useState('') + const [showList, setShowList] = useState(false) + const end = useRef<HTMLDivElement>(null) + const busy = chat.status === 'submitted' || chat.status === 'streaming' + const last = chat.messages[chat.messages.length - 1] + + useEffect(() => { + end.current?.scrollIntoView({ block: 'end' }) + }, [chat.messages]) + + function send() { + const text = input.trim() + if (!text || busy) return + setInput('') + // A message refused before it starts leaves the transcript as it was: give it back. + chat.sendMessage(text).catch(() => setInput(text)) + } + + function open(id?: string) { + if (id) chat.selectConversation(id) + else chat.newConversation() + setShowList(false) + } + + return ( + <div className="chat"> + <aside className={showList ? 'sidebar open' : 'sidebar'}> + <button className="primary" onClick={() => open()}>New chat</button> + <nav> + {chat.conversations.map((c) => ( + <div key={c.id} className={c.id === chat.conversationId ? 'conv active' : 'conv'}> + <button className="conv-title" onClick={() => open(c.id)}> + {c.title || 'New chat'} + </button> + <button + className="conv-delete" + aria-label="Delete conversation" + onClick={() => chat.deleteConversation(c.id)} + > + × + </button> + </div> + ))} + </nav> + </aside> + {showList && <div className="backdrop" onClick={() => setShowList(false)} />} + + <section className="main"> + <header> + <button className="menu" aria-label="Conversations" onClick={() => setShowList(true)}> + ☰ + </button> + <h1>{CHAT_NAME}</h1> + </header> + + <div className="messages"> + <div className="thread"> + {chat.messages.length === 0 && ( + <p className="empty">Ask {CHAT_NAME} anything.</p> + )} + {chat.messages.map((m) => + m.tool ? ( + <div key={m.id} className="tool"> + {m.tool.name} + {m.tool.status === 'running' ? '…' : m.tool.status === 'error' ? ' failed' : ''} + </div> + ) : m.role === 'user' || m.role === 'assistant' ? ( + <div key={m.id} className={'msg ' + m.role + (m.success ? '' : ' failed')}> + {m.content || (m.pending && <span className="thinking">Thinking…</span>)} + </div> + ) : null + )} + {busy && last?.role === 'user' && ( + <div className="msg assistant"> + <span className="thinking">Thinking…</span> + </div> + )} + <div ref={end} /> + </div> + </div> + + <footer> + {chat.status === 'error' && chat.error && ( + <div className="error-banner">{chat.error.message}</div> + )} + <form + onSubmit={(e) => { + e.preventDefault() + send() + }} + > + <textarea + value={input} + rows={1} + placeholder={'Message ' + CHAT_NAME} + onChange={(e) => setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault() + send() + } + }} + /> + {busy ? ( + <button type="button" onClick={() => chat.stop()}>Stop</button> + ) : ( + <button type="submit" className="primary" disabled={!input.trim()}> + Send + </button> + )} + </form> + </footer> + </section> + </div> + ) +} +` + +const chatAppCss = `${themeCss} +button { + font: inherit; + cursor: pointer; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--card); + color: var(--fg-strong); + padding: 8px 14px; +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +button.primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +button:disabled { + opacity: 0.5; + cursor: default; +} + +.chat { + display: grid; + grid-template-columns: 240px 1fr; + height: 100vh; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + background: var(--sunken); + border-right: 1px solid var(--border); + overflow-y: auto; +} + +.sidebar nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.conv { + display: flex; + align-items: center; + border-radius: 6px; +} + +.conv:hover, +.conv.active { + background: var(--card); +} + +.conv-title { + flex: 1; + min-width: 0; + border: 0; + background: none; + padding: 6px 8px; + text-align: left; + color: var(--fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.conv-delete { + border: 0; + background: none; + padding: 4px 8px; + color: var(--muted); + visibility: hidden; +} + +.conv:hover .conv-delete { + visibility: visible; +} + +.main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: var(--card); + border-bottom: 1px solid var(--border); +} + +header h1 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--fg-strong); +} + +.menu { + display: none; + padding: 4px 10px; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 24px 20px; +} + +.thread { + max-width: 760px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.empty { + margin: 64px 0 0; + text-align: center; + color: var(--muted); +} + +.msg { + max-width: 85%; + padding: 8px 12px; + border-radius: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.msg.user { + align-self: flex-end; + background: var(--accent); + color: #fff; +} + +.msg.assistant { + align-self: flex-start; + background: var(--card); + border: 1px solid var(--border); + color: var(--fg-strong); +} + +.msg.failed { + border-color: var(--error); +} + +.tool { + align-self: flex-start; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + color: var(--muted); +} + +.thinking { + color: var(--muted); + animation: pulse 1.4s ease-in-out infinite; +} + +@keyframes pulse { + 50% { + opacity: 0.4; + } +} + +footer { + padding: 12px 20px; + background: var(--card); + border-top: 1px solid var(--border); +} + +footer form, +.error-banner { + max-width: 760px; + margin: 0 auto; +} + +footer form { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.error-banner { + margin-bottom: 8px; + padding: 8px 12px; + border-radius: 8px; + background: var(--error-soft); + color: var(--error); +} + +textarea { + flex: 1; + resize: none; + field-sizing: content; + min-height: 40px; + max-height: 200px; + padding: 9px 12px; + font: inherit; + color: var(--fg-strong); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; +} + +textarea:focus { + outline: none; + border-color: var(--accent); +} + +.backdrop { + display: none; +} + +@media (max-width: 720px) { + .chat { + grid-template-columns: 1fr; + } + + .sidebar { + position: fixed; + inset: 0 auto 0 0; + width: 260px; + z-index: 2; + transform: translateX(-100%); + transition: transform 0.2s; + } + + .sidebar.open { + transform: none; + } + + .backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 1; + background: rgb(0 0 0 / 0.3); + } + + .menu { + display: inline-flex; + } + + .conv-delete { + visibility: visible; + } +} +` + +/** React chat over a saved AI agent, or a flow in chat mode, at `path`, through its `chat` + * runnable. */ +export function createChatRawApp({ + path, + runType, + summary, + schema +}: { + path: string + runType: 'agent' | 'flow' + summary?: string + schema?: Record<string, any> +}): RawAppImport { + const name = summary?.trim() || (path.split('/').pop() ?? path) + return { + summary: `Chat with ${name}`, + openInAiSession: true, + value: { + files: { + ...react19Template, + '/App.tsx': chatAppTsx(name), + '/index.css': chatAppCss, + // The runnable transport (`{ wmill, runnable }`) sends `memory_id` as a query parameter from 1.851.0. + '/package.json': JSON.stringify( + { + dependencies: { + react: '19.0.0', + 'react-dom': '19.0.0', + '@windmill-labs/chat-sdk': '^1.851.0' + }, + devDependencies: { '@types/react-dom': '^19.0.0', '@types/react': '^19.0.0' } + }, + null, + 4 + ) + }, + runnables: { + chat: { name, type: 'path', runType, path, schema: schema ?? {}, fields: {} } + } + } + } +} diff --git a/frontend/src/lib/components/diff_drawer.ts b/frontend/src/lib/components/diff_drawer.ts index c1e7e8980a..0b756b37e4 100644 --- a/frontend/src/lib/components/diff_drawer.ts +++ b/frontend/src/lib/components/diff_drawer.ts @@ -17,6 +17,15 @@ export type DiffVersionOption = { * this is the picker's own appetite, not their default. */ export const VERSION_PAGE_SIZE = 20 +/** Offers an AI review of the shown change, for a diff of a draft against its deployed version. */ +export type DiffAiReview = { + workspaceId: string + kind: string + path: string + /** Never deployed: the left side is a placeholder, not a version. */ + added?: boolean +} + export type DiffDrawerDiff = | { mode: 'normal' @@ -52,6 +61,7 @@ export type DiffDrawerDiff = current: Value defaultDiffType?: 'deployed' | 'draft' button?: { text: string; onClick: () => void } + aiReview?: DiffAiReview } | { mode: 'simple' @@ -59,6 +69,7 @@ export type DiffDrawerDiff = current: Value title: string button?: { text: string; onClick: () => void } + aiReview?: DiffAiReview } export interface DiffDrawerI { diff --git a/frontend/src/lib/components/flows/agentActions.ts b/frontend/src/lib/components/flows/agentActions.ts index b99229d4d3..627bfe8d5e 100644 --- a/frontend/src/lib/components/flows/agentActions.ts +++ b/frontend/src/lib/components/flows/agentActions.ts @@ -1,5 +1,11 @@ -import { Copy, Eye, FileUp, History, List, Shield, Trash } from 'lucide-svelte' -import { ResourceService } from '$lib/gen' +import { Copy, Eye, FileUp, History, LayoutDashboard, List, Shield, Trash } from 'lucide-svelte' +import { DraftService, ResourceService, type AiAgent, type OpenFlow } from '$lib/gen' +import { mintDraftPath } from '$lib/mintDraftPath' +import { keepsManagedMemory } from './agentFormFields' +import type { AIAgentConfig } from './agentResourceUtils' +import { AGENT_CHAT_SCHEMA } from './conversations/agentEditorChat' +import { inlineAgentDraft } from './linkedAgentDrafts' +import { nextId } from './flowModuleNextId' import { sendUserToast } from '$lib/toast' import { copyToClipboard, type Item } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' @@ -18,6 +24,61 @@ export async function deleteAgent(workspace: string, path: string): Promise<bool } } +/** + * Opens a new flow whose one step is the agent, reading the message and files from the flow's + * inputs, and chatting when the agent keeps the conversation. The step links the deployed agent + * at `path`, or with `inline` carries `config` itself, for an agent that was never deployed. + * `beforeOpen` runs once the flow is handed over, and a throw from it keeps the page where it is. + */ +export async function openAgentInFlow(opts: { + workspace: string + path: string + config: AIAgentConfig + inline: boolean + beforeOpen?: () => Promise<unknown> +}): Promise<void> { + const linked = { + type: 'aiagent', + agent: opts.path, + tools: [], + input_transforms: { + user_message: { type: 'javascript', expr: 'flow_input.user_message' }, + user_attachments: { type: 'javascript', expr: 'flow_input.user_attachments' } + } + } as AiAgent & { agent: string } + const value = opts.inline ? inlineAgentDraft(linked, opts.config) : linked + // An inlined agent brings its tools' ids, which the step's own must not repeat. + const id = nextId({}, { summary: '', value: { modules: [{ id: '', value }] } }) + const flow: OpenFlow & { path: string; description: string; extra_perms: {} } = { + path: opts.path, + summary: '', + description: '', + extra_perms: {}, + value: { + ...(keepsManagedMemory(opts.config.memory) ? { chat_input_enabled: true } : {}), + modules: [{ id, value }] + }, + schema: AGENT_CHAT_SCHEMA + } + // Saved as a draft before anything is let go, so the flow outlives a refresh of its editor: a + // new flow's editor writes nothing until its first edit. The editor opens it as it opens any + // draft-only flow, in the workspace named here rather than the one navigation last stored. + const draftPath = mintDraftPath() + try { + await DraftService.updateDraft({ + workspace: opts.workspace, + kind: 'flow', + path: draftPath, + requestBody: { value: flow } + }) + await opts.beforeOpen?.() + } catch (err) { + sendUserToast(`Could not open the flow editor: ${err}`, true) + return + } + await goto(`/flows/edit/${draftPath}?workspace=${encodeURIComponent(opts.workspace)}`) +} + /** An agent's menu, as its home row and its page both show it. */ export function agentMenuItems(agent: { path: string @@ -33,6 +94,9 @@ export function agentMenuItems(agent: { onDeploy: () => void onHistory: () => void onDelete: (event?: MouseEvent) => void + /** Set where the user may build an app over the agent: a chat app when it keeps a chat, a form + * over its message otherwise. */ + buildApp?: { chat: boolean; action: () => void } }): Item[] { const deployable = !agent.noCode && @@ -62,6 +126,15 @@ export function agentMenuItems(agent: { ]), { displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(agent.path) } ]), + ...(agent.buildApp + ? [ + { + displayName: agent.buildApp.chat ? 'Build chat app' : 'Build app', + icon: LayoutDashboard, + action: agent.buildApp.action + } + ] + : []), { displayName: 'Permissions', icon: Shield, diff --git a/frontend/src/lib/components/flows/content/AgentDetailPage.svelte b/frontend/src/lib/components/flows/content/AgentDetailPage.svelte index 8845311b42..28b55d343f 100644 --- a/frontend/src/lib/components/flows/content/AgentDetailPage.svelte +++ b/frontend/src/lib/components/flows/content/AgentDetailPage.svelte @@ -1,7 +1,6 @@ <script lang="ts"> import { untrack } from 'svelte' - import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte' - import { Pane, Splitpanes } from 'svelte-splitpanes' + import { FlaskConical, FormInput, LayoutDashboard, MessageSquare, Pen } from 'lucide-svelte' import { resource } from 'runed' import { goto } from '$lib/navigation' import { copilotInfo } from '$lib/aiStore' @@ -16,6 +15,11 @@ import ShareModal from '$lib/components/ShareModal.svelte' import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte' + import BuildChatAppCard from '$lib/components/details/BuildChatAppCard.svelte' + import { + createChatRawApp, + createRawAppFromAgent + } from '$lib/components/details/createRawAppFromScript' import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte' import RunForm from '$lib/components/RunForm.svelte' import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte' @@ -86,6 +90,15 @@ const editRights = useEditRights(() => ws) let canAuthor = $derived(editRights.roleCanAuthor('agent')) let canCode = $derived(editRights.capabilities.code) + + async function buildApp() { + const app = chatAvailable + ? createChatRawApp({ path, runType: 'agent' }) + : createRawAppFromAgent(path) + // The raw app editor reads the payload from sessionStorage on mount. + sessionStorage.setItem('rawAppImport', JSON.stringify(app)) + await onNavigate('/apps_raw/add') + } let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && canAuthor) // Recorded once the agent is in: a path with nothing deployed never loads one. Not from a @@ -133,11 +146,11 @@ let history: AgentHistory | undefined = $state(undefined) let deleteOpen = $state(false) let evalsModal: AgentEvalsModal | undefined = $state(undefined) - let clientWidth = $state(window.innerWidth) - let pickedMobileTab = $state('agent') + let pickedTab = $state<'agent' | 'api' | 'slack'>('agent') // The page is reused across agents, and an agent without managed memory has no Slack tab. - let mobileTab = $derived(pickedMobileTab === 'slack' && !chatAvailable ? 'api' : pickedMobileTab) - let wide = $derived(clientWidth >= 1024) + let tab = $derived( + pickedTab !== 'agent' && pickedTab !== 'api' && !chatAvailable ? 'api' : pickedTab + ) const slack = resource( () => ({ workspace: ws, path }), @@ -152,7 +165,7 @@ function onKeyDown(event: KeyboardEvent) { if (!active) return if (event.defaultPrevented || event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) return - if (!runForm || testPane?.mode === 'chat' || (!wide && mobileTab !== 'agent')) return + if (!runForm || testPane?.mode === 'chat' || tab !== 'agent') return if (isValid) { event.preventDefault() runForm.run() @@ -186,27 +199,45 @@ () => ws, () => getDeployUiSettings() ) - // A role that cannot build agents gets no menu, as on a flow's page. + // The chat template runs the agent by path and holds no code, so raw app rights are enough, as + // on the sidebar card. The form app starts from the full template, as on a flow's page. + let buildAppItem = $derived( + (chatAvailable ? editRights.roleCanAuthor('raw_app') : editRights.capabilities.apps === 'full') + ? { chat: chatAvailable, action: buildApp } + : undefined + ) + // A role that cannot build agents gets no menu, as on a flow's page, but for the app. let menuItems = $derived( - config != undefined && canAuthor - ? agentMenuItems({ - path, - canWrite: canEdit, - noCode: !canCode, - wsSpecific: agent?.state?.wsSpecific, - deployUiSettings: deployUiSettings.current, - onPermissions: () => shareModal?.openDrawer?.(path, 'resource'), - onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'), - onHistory: () => history?.open(), - onDelete: () => (deleteOpen = true) - }).map((item) => ({ - label: item.displayName, - Icon: item.icon, - onclick: (e: MouseEvent) => item.action?.(e), - disabled: item.disabled, - color: item.type === 'delete' ? ('red' as const) : undefined - })) - : [] + config == undefined + ? [] + : !canAuthor + ? buildAppItem + ? [ + { + label: buildAppItem.chat ? 'Build chat app' : 'Build app', + Icon: LayoutDashboard, + onclick: buildApp + } + ] + : [] + : agentMenuItems({ + path, + canWrite: canEdit, + noCode: !canCode, + wsSpecific: agent?.state?.wsSpecific, + deployUiSettings: deployUiSettings.current, + onPermissions: () => shareModal?.openDrawer?.(path, 'resource'), + onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'), + onHistory: () => history?.open(), + onDelete: () => (deleteOpen = true), + buildApp: buildAppItem + }).map((item) => ({ + label: item.displayName, + Icon: item.icon, + onclick: (e: MouseEvent) => item.action?.(e), + disabled: item.disabled, + color: item.type === 'delete' ? ('red' as const) : undefined + })) ) </script> @@ -319,15 +350,10 @@ {/if} {/snippet} </DetailPageHeader> - <!-- Below `lg` the split would squeeze the chat, so the panel's sections become tabs of the - page, as on a script's or a flow's page. They cover the agent rather than replacing it: - unmounting the host destroys the chat and any turn in flight, so the agent pane keeps one - parent at every width. --> - {#if !wide && ws && config} - <Tabs - bind:selected={() => mobileTab, (t) => (pickedMobileTab = t)} - wrapperClass="flex-none no-scrollbar" - > + <!-- Tabs at every width, as a chat-mode flow's page has them. They cover the agent rather than + replacing it: unmounting the host destroys the chat and any turn in flight. --> + {#if ws && config} + <Tabs bind:selected={() => tab, (t) => (pickedTab = t)} wrapperClass="flex-none no-scrollbar"> <Tab value="agent" label={testPane?.mode === 'chat' ? 'Chat' : 'Run form'} /> <Tab value="api" label="API" /> {#if chatAvailable} @@ -335,22 +361,13 @@ {/if} </Tabs> {/if} - <div class="relative flex-1 min-h-0" bind:clientWidth> - <Splitpanes> - <Pane size={wide ? 65 : 100} minSize={50}> - <div class="h-full" inert={!wide && mobileTab !== 'agent'}> - {@render agentPane()} - </div> - </Pane> - {#if wide && ws && config} - <Pane size={35} minSize={15}> - {@render usePane(ws)} - </Pane> - {/if} - </Splitpanes> - {#if !wide && ws && config} - <div class={['absolute inset-0 z-10 bg-surface', mobileTab === 'agent' && 'hidden']}> - {@render usePane(ws, mobileTab === 'slack' ? 'slack' : 'api')} + <div class="relative flex-1 min-h-0"> + <div class="h-full" inert={tab !== 'agent'}> + {@render agentPane()} + </div> + {#if ws && config} + <div class={['absolute inset-0 z-10 bg-surface', tab === 'agent' && 'hidden']}> + {@render usePane(tab === 'agent' ? 'api' : tab)} </div> {/if} </div> @@ -372,6 +389,11 @@ onOpenConfig={() => configModal?.open()} onRunStarted={embedded || pendingRun ? runStarted : undefined} > + {#snippet chatSidebarFooter({ expanded })} + {#if ws} + <BuildChatAppCard kind="agent" {path} workspace={ws} {expanded} {onNavigate} /> + {/if} + {/snippet} {#snippet viewForm({ schema, run, loading, actions })} {#if config && !chatAvailable} <Alert type="info" size="xs" title="Managed memory is off" class="mb-4"> @@ -408,10 +430,9 @@ {/key} {/snippet} -{#snippet usePane(ws: string, section?: 'api' | 'slack')} +{#snippet usePane(section: 'api' | 'slack')} <AgentUsePanel agentPath={path} - workspace={ws} isAdmin={!!(operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)} chat={chatAvailable} slackLoading={slack.loading && !slack.current} diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index 0bc78aa311..1299c2a507 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -86,6 +86,10 @@ } ] > + /** A hint pinned under the configuration, outside its scroll. */ + configFooter?: import('svelte').Snippet + /** Pinned at the foot of the chat's conversations sidebar. */ + chatSidebarFooter?: import('svelte').Snippet<[{ expanded: boolean }]> } let { @@ -99,7 +103,9 @@ view = false, onOpenConfig = undefined, onRunStarted = undefined, - viewForm = undefined + viewForm = undefined, + configFooter = undefined, + chatSidebarFooter = undefined }: Props = $props() /** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a @@ -611,31 +617,40 @@ </div> {#snippet configPane()} - <div class="h-full min-h-0 overflow-auto"> - <PropPickerWrapper pickableProperties={stepPropPicker?.pickableProperties} noPadding sidePane> - <AiAgentStepInputs - class="px-4 pt-4 pb-8" - {schema} - filter={brainFilter} - previousModuleId={undefined} + <div class="h-full min-h-0 flex flex-col"> + <div class="flex-1 min-h-0 overflow-auto"> + <PropPickerWrapper pickableProperties={stepPropPicker?.pickableProperties} - extraLib={stepPropPicker?.extraLib ?? 'missing extraLib'} - {enableAi} - {workspace} - staticOnly - visibilityKey={`agent:${path}`} - {tools} - {readOnly} - onSelectTool={(id) => onSelectTool?.(id)} - onAddTool={readOnly ? undefined : addTool} - onDeleteTool={readOnly ? undefined : deleteTool} - toolPickerPortal="#agent-editor" - bind:args={ - () => (agentValue?.input_transforms ?? {}) as Record<string, InputTransform>, - (v) => agentValue && (agentValue.input_transforms = v) - } - /> - </PropPickerWrapper> + noPadding + sidePane + > + <AiAgentStepInputs + class="px-4 pt-4 pb-8" + {schema} + filter={brainFilter} + previousModuleId={undefined} + pickableProperties={stepPropPicker?.pickableProperties} + extraLib={stepPropPicker?.extraLib ?? 'missing extraLib'} + {enableAi} + {workspace} + staticOnly + visibilityKey={`agent:${path}`} + {tools} + {readOnly} + onSelectTool={(id) => onSelectTool?.(id)} + onAddTool={readOnly ? undefined : addTool} + onDeleteTool={readOnly ? undefined : deleteTool} + toolPickerPortal="#agent-editor" + bind:args={ + () => (agentValue?.input_transforms ?? {}) as Record<string, InputTransform>, + (v) => agentValue && (agentValue.input_transforms = v) + } + /> + </PropPickerWrapper> + </div> + {#if configFooter} + <div class="shrink-0 px-3 py-1.5">{@render configFooter()}</div> + {/if} </div> {/snippet} @@ -755,6 +770,7 @@ inputSchema={AGENT_CHAT_SCHEMA} flowModules={chatModules} composerSettings={onOpenConfig ? configButton : undefined} + sidebarFooter={chatSidebarFooter} /> </div> </div> diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index 2de3651804..e1932ed33a 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -28,6 +28,10 @@ showAgentEditorView } from '../agentEditorStore.svelte' import { agentConfigToInputTransforms } from '../agentResourceUtils' + import { openAgentInFlow } from '../agentActions' + import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' + import { useEditRights } from '$lib/operatorWriteRights' + import { userStore } from '$lib/stores' import { publishLinkedAgentTools } from '../flowState' import { linkedModulesForAgent, linkedToolsScope } from '../linkedAgentToolsStore.svelte' import AgentEditorHost from './AgentEditorHost.svelte' @@ -38,7 +42,6 @@ import PathEditPopover from '$lib/components/PathEditPopover.svelte' import { emptyString } from '$lib/utils' import Tooltip from '$lib/components/Tooltip.svelte' - import { useEditRights } from '$lib/operatorWriteRights' import { itemDisplayName } from '$lib/components/sessions/previewRouter' import { UserDraft } from '$lib/userDraft.svelte' import OpenInSessionButton, { @@ -328,20 +331,79 @@ let deployingFor = $state<WriteTarget | undefined>(undefined) - async function onDeploy() { + async function onDeploy(): Promise<boolean> { saving = true deployingFor = currentWriteTarget() try { - await host?.deploy() + return (await host?.deploy()) ?? false } finally { saving = false deployingFor = undefined } } + // Only where the agent stands alone: opened from a flow's step, it is already in one. Linking + // a deployed agent as it is only reads it. Not for operators: the server refuses their flows + // a linked agent, and an inlined one's tools may hold code. + let offerFlow = $derived( + target?.host === undefined && + draft?.state != undefined && + !draft.loading && + !refused && + !$userStore?.operator && + editRights.roleCanAuthor('flow') && + (!readOnly || (!draft.noDeployed && !draft.sync.hasDraft)) + ) + let openingFlow = $state(false) + /** Set while a deploy is followed by the flow's page: the page loads next, so the deploy's own + * navigation would race it. Holds the path the deploy wrote. */ + let handOff = $state<{ savedPath?: string } | undefined>(undefined) + + async function useInFlow() { + const state = draft?.state + const at = target + const workspace = ws + if (!draft || !state || !at || !workspace) return + openingFlow = true + try { + if (draft.noDeployed) { + // Never a workspace item: it becomes the flow's step, and its draft goes. Through the + // sync, so an autosave still pending cannot write it back. + const sync = draft.sync + await openAgentInFlow({ + workspace, + path: state.path, + config: state.args, + inline: true, + beforeOpen: async () => sync.discard(at.path, undefined) + }) + return + } + // The flow links the deployed agent, so it has to run what is shown here. + const config = state.args + let path = at.path + if (draft.sync.hasDraft) { + handOff = {} + try { + if (!(await onDeploy()) || !handOff.savedPath) return + path = handOff.savedPath + } finally { + handOff = undefined + } + } + await openAgentInFlow({ workspace, path, config, inline: false }) + } finally { + openingFlow = false + } + } + /** What a successful deploy has to reconcile. `savedPath` is the path it wrote, which a rename * moves off the one the editor opened. */ async function onSaved(savedPath: string) { + if (handOff) { + handOff.savedPath = savedPath + return + } const at = deployingFor ?? currentWriteTarget() // Before the rename is announced: it finds the steps to refresh under the old path. const reconciled = reconcile(at, savedPath) @@ -665,9 +727,34 @@ onSelectTool={(id) => showTool(id)} {onSaved} isNew={target?.isNew} + configFooter={offerFlow ? flowFooter : undefined} /> {/snippet} + {#snippet flowFooter()} + <div class="flex flex-wrap items-center gap-x-1.5 text-xs text-hint"> + Need to fetch context first, process the output, or add other steps? + <Button + unifiedSize="xs" + variant="subtle" + startIcon={{ icon: BarsStaggered, props: { style: 'fill: currentColor;' } }} + loading={openingFlow} + onClick={useInFlow} + title={draft?.noDeployed + ? 'It becomes the step of a new flow, and this draft is discarded.' + : draft?.sync.hasDraft + ? 'Your changes are deployed, then a new flow links this agent.' + : 'A new flow links this agent, which stays configured here.'} + > + {draft?.noDeployed + ? 'Turn into a flow' + : draft?.sync.hasDraft + ? 'Deploy and use in a flow' + : 'Use in a flow'} + </Button> + </div> + {/snippet} + {#snippet settingsFields()} {#if draft} <AgentSettings diff --git a/frontend/src/lib/components/flows/content/AgentSettings.svelte b/frontend/src/lib/components/flows/content/AgentSettings.svelte index 5ed0799df3..b427ad6d3e 100644 --- a/frontend/src/lib/components/flows/content/AgentSettings.svelte +++ b/frontend/src/lib/components/flows/content/AgentSettings.svelte @@ -10,7 +10,6 @@ import PermissionedAsLine from '$lib/components/triggers/PermissionedAsLine.svelte' import AgentSlackSettings from './AgentSlackSettings.svelte' import { WorkspaceService } from '$lib/gen' - import { userStore } from '$lib/stores' import { useActingUser } from '$lib/actingUser.svelte' import type { AgentDraftHandle, AgentRunAs } from '../agentDraft.svelte' @@ -36,8 +35,8 @@ let nextRunAs = $derived( pickedRunAs?.preserve && pickedRunAs.permissionedAs ? pickedRunAs.permissionedAs - : $userStore?.username - ? `u/${$userStore.username}` + : acting.current?.username + ? `u/${acting.current.username}` : undefined ) // Only a workspace that deploys somewhere has anything to keep an agent out of. diff --git a/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte b/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte index 5e1e4f020d..0b4ebe01c7 100644 --- a/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte +++ b/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte @@ -9,7 +9,7 @@ import TextInput from '$lib/components/text_input/TextInput.svelte' import Toggle from '$lib/components/Toggle.svelte' import { SlackAgentsService, type SlackAgentChannel } from '$lib/gen' - import { userStore } from '$lib/stores' + import { useActingUser } from '$lib/actingUser.svelte' import { sendUserToast } from '$lib/toast' import type { AgentDraftHandle } from '../agentDraft.svelte' @@ -26,16 +26,18 @@ let { draft, path, workspace, nextRunAs }: Props = $props() + const acting = useActingUser(() => workspace) + let readOnly = $derived(!draft.canWrite) let obo = $derived(!!draft.state?.args.run_on_behalf_of) let runsAs = $derived( readOnly ? (draft.onBehalfOf ?? 'its run-as user') - : !nextRunAs || nextRunAs === `u/${$userStore?.username}` + : !nextRunAs || nextRunAs === `u/${acting.current?.username}` ? 'you' : nextRunAs ) - let isAdmin = $derived(!!$userStore?.is_admin) + let isAdmin = $derived(!!acting.current?.is_admin) const slack = resource( () => ({ ws: workspace, path, deployed: !draft.noDeployed }), diff --git a/frontend/src/lib/components/flows/content/AgentUsePanel.svelte b/frontend/src/lib/components/flows/content/AgentUsePanel.svelte index 75a1a04205..1689f53e65 100644 --- a/frontend/src/lib/components/flows/content/AgentUsePanel.svelte +++ b/frontend/src/lib/components/flows/content/AgentUsePanel.svelte @@ -1,28 +1,8 @@ <script lang="ts"> - import { Copy } from 'lucide-svelte' - import CollapseLink from '$lib/components/CollapseLink.svelte' - import { bash, markdown, typescript } from 'svelte-highlight/languages' - import { Alert, Badge, Button, CopyButton, Tab, TabContent, Tabs } from '$lib/components/common' - import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' - import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte' - import Label from '$lib/components/Label.svelte' - import TextInput from '$lib/components/text_input/TextInput.svelte' + import { Alert, Badge, Button, CopyButton, TabContent, Tabs } from '$lib/components/common' import Tooltip from '$lib/components/Tooltip.svelte' - import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' - import UserSettings from '$lib/components/UserSettings.svelte' import type { SlackAgentChannel } from '$lib/gen' - import { userStore } from '$lib/stores' - import { copyToClipboard, generateRandomString } from '$lib/utils' - import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' - import { - curlExample, - fetchExample, - integrationPrompt, - runUrl, - sdkExample, - sdkReactExample - } from './agentApiExamples' + import WebhooksPanel from '$lib/components/triggers/webhook/WebhooksPanel.svelte' /** * The ways to use an agent outside its page: in the Slack channels it is shared in, set in its @@ -30,7 +10,6 @@ */ let { agentPath, - workspace, isAdmin, chat, slackLoading, @@ -45,7 +24,6 @@ section }: { agentPath: string - workspace: string isAdmin: boolean /** Whether the agent keeps the conversation. Slack is offered only then: a thread is one. */ chat: boolean @@ -66,53 +44,15 @@ /** Whether the user can change its Slack settings: anyone who can edit it. */ canShare: boolean channels: SlackAgentChannel[] - /** Shows only this section, without the panel's own tabs: the page offers them as its tabs. */ - section?: 'api' | 'slack' + /** The section shown: the page offers them as its tabs. */ + section: 'api' | 'slack' } = $props() - let pickedTab = $state('api') - let tab = $derived(section ?? (chat ? pickedTab : 'api')) let handle = $derived(`+${agentPath.split('/').pop()}`) - - let userSettings: UserSettings | undefined = $state(undefined) - let token = $state('') - let target = $derived({ - api: `${apiBaseUrl('webhooks')}/api/w/${workspace}`, - agentPath, - chat, - token - }) - let body = JSON.stringify({ user_message: 'Hello' }, null, 2) - let headers = $derived( - JSON.stringify( - { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - null, - 2 - ) - ) - let apiTab = $state('prompt') - let tsFlavor = $state('react') </script> -<UserSettings - bind:this={userSettings} - on:tokenCreated={(e) => (token = e.detail)} - newTokenWorkspace={workspace} - newTokenLabel={`agent-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`} - scopes={[`jobs:run:agents:${agentPath}`]} -/> - <div class="flex flex-col h-full"> - <Tabs - bind:selected={() => tab, (t) => (pickedTab = t)} - hideTabs={section !== undefined} - wrapperClass="flex-none w-full" - > - <Tab value="api" label="API" /> - {#if chat} - <Tab value="slack" label="Slack" /> - {/if} - + <Tabs selected={section} hideTabs wrapperClass="flex-none w-full"> {#snippet content()} <div class="min-h-0 grow overflow-y-auto"> <TabContent value="slack" class="flex flex-col gap-6 p-4"> @@ -223,93 +163,15 @@ {/if} </TabContent> - <TabContent value="api" class="flex flex-col gap-6 p-4"> - <Label label="Token"> - <div class="flex flex-col gap-2"> - <TextInput - bind:value={token} - inputProps={{ placeholder: 'Paste your token here once created' }} - class="!text-xs !font-normal" - /> - <div> - <Button - variant="default" - unifiedSize="sm" - onClick={() => userSettings?.openDrawer()} - > - Create an agent-specific token - <Tooltip light> - The token can only run this agent. It is safe to share as it cannot be used to - impersonate you. - </Tooltip> - </Button> - </div> - </div> - </Label> - <div class="flex flex-col"> - <Tabs bind:selected={apiTab}> - <Tab value="prompt" label="AI prompt" /> - <Tab value="ts" label="TypeScript" /> - <Tab value="http" label="HTTP" /> - {#snippet content()} - <TabContent value="ts" class="flex flex-col gap-2 mt-2"> - {#if chat} - <ToggleButtonGroup bind:selected={tsFlavor} noWFull> - {#snippet children({ item })} - <ToggleButton value="react" label="React" {item} small /> - <ToggleButton value="any" label="Any framework" {item} small /> - <ToggleButton value="fetch" label="No SDK" {item} small /> - {/snippet} - </ToggleButtonGroup> - {/if} - <CopyableCodeBlock - code={!chat || tsFlavor === 'fetch' - ? fetchExample(target) - : tsFlavor === 'react' - ? sdkReactExample(target) - : sdkExample(target)} - language={typescript} - /> - </TabContent> - <TabContent value="http" class="flex flex-col gap-6 mt-2"> - <Label label="Url"> - <ClipboardPanel - content={runUrl(target) + (chat ? '?memory_id=<conversation id>' : '')} - /> - </Label> - <Label label="Body"><ClipboardPanel content={body} /></Label> - <Label label="Headers"><ClipboardPanel content={headers} /></Label> - <Label label="Curl"> - <span class="text-xs text-secondary"> - Runs the agent and waits for its answer. The TypeScript tab streams it - instead. - </span> - <CopyableCodeBlock code={curlExample(target)} language={bash} /> - </Label> - </TabContent> - <TabContent value="prompt" class="flex flex-col gap-2 mt-2"> - <span class="text-xs text-secondary"> - Share this with your coding assistant. It assumes your token is in <code - >WINDMILL_TOKEN</code - >. - </span> - <div> - <Button - variant="default" - unifiedSize="sm" - startIcon={{ icon: Copy }} - onClick={() => copyToClipboard(integrationPrompt(target))} - > - Copy prompt - </Button> - </div> - <CollapseLink text="Show prompt"> - <CopyableCodeBlock code={integrationPrompt(target)} language={markdown} wrap /> - </CollapseLink> - </TabContent> - {/snippet} - </Tabs> - </div> + <TabContent value="api" class="p-4"> + <WebhooksPanel + agent + {chat} + path={agentPath} + token="" + args={{ user_message: 'Hello' }} + scopes={[`jobs:run:agents:${agentPath}`]} + /> </TabContent> </div> {/snippet} diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index f99fb91dee..6e60477811 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -176,6 +176,7 @@ isDeployed={savedFlow && !savedFlow?.draft_only} schema={flowStore.val.schema} {onDeployTrigger} + chatMode={!!flowStore.val.value.chat_input_enabled} /> {:else if selectedId?.startsWith('subflow:')} <ExpandedSubflowStep diff --git a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte index ff3151a04b..af1ff81eaa 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte @@ -147,8 +147,9 @@ type="info" title="Preprocessor is called when the flow is triggered by API or email" > - It prepares arguments for the flow. Besides request arguments, the preprocessor receives a - `wm_trigger` argument with trigger details. + It prepares arguments for the flow. It receives an `event` argument: the trigger's `kind` + (such as `webhook` or `email`) and what the trigger received, a webhook's `body` and + `headers` for instance. </Alert> </div> {/if} diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index 321caeb190..fe7b86628d 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -63,6 +63,8 @@ subject?: 'flow' | 'agent' /** The host's own controls, after the model's in the composer's footer. */ composerSettings?: import('svelte').Snippet + /** Pinned at the foot of the conversations sidebar. */ + sidebarFooter?: import('svelte').Snippet<[{ expanded: boolean }]> } let { @@ -78,7 +80,8 @@ frame = 'top', conversationKind = 'deployed', subject = 'flow', - composerSettings = undefined + composerSettings = undefined, + sidebarFooter = undefined }: Props = $props() const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext') @@ -202,6 +205,7 @@ {poolState} defaultKind={conversationKind} canFilterKind={conversationKind !== 'deployed' && subject === 'flow'} + footer={sidebarFooter} /> {/if} <!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of diff --git a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte index 8ab69377e2..cb26944402 100644 --- a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte @@ -45,6 +45,8 @@ * put editor scratch in front of the flow's users. */ canFilterKind?: boolean + /** Pinned under the list, open or collapsed. */ + footer?: import('svelte').Snippet<[{ expanded: boolean }]> } let { @@ -52,7 +54,8 @@ pool, poolState, defaultKind = 'deployed', - canFilterKind = false + canFilterKind = false, + footer = undefined }: Props = $props() let expanded = $state(false) @@ -450,4 +453,8 @@ {/snippet} </InfiniteList> </div> + {#if footer} + <!-- mt-auto: collapsed, the list above is hidden and no longer pushes it down. --> + <div class="flex-shrink-0 mt-auto p-1">{@render footer({ expanded })}</div> + {/if} </div> diff --git a/frontend/src/lib/components/icons/ArubaIcon.svelte b/frontend/src/lib/components/icons/ArubaIcon.svelte new file mode 100644 index 0000000000..b08dc1bc0c --- /dev/null +++ b/frontend/src/lib/components/icons/ArubaIcon.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="401 -145.5 390 390" fill="#FF8300" xmlns="http://www.w3.org/2000/svg"> +<path d="M668.5,22.9c-7.9,0-15.2,2.3-21.3,6.3v-5.9c-0.7-15.8-13.6-20.9-17-22.5h-0.4v98.3c0,0,11.6-1.2,15.9-7.6c6.4,4.6,14.2,7.4,22.8,7.4c21.4,0,38.7-17,38.7-37.9S689.8,22.9,668.5,22.9z M668.5,81.8c-11.9,0-21.5-9.4-21.5-21s9.6-21,21.5-21s21.4,9.4,21.4,21S680.2,81.8,668.5,81.8z M750.2,22.9c-21.4,0-38.7,17-38.7,37.9s17.3,37.9,38.7,37.9c8.8,0,17-2.9,23.5-7.8c4,6.6,15.2,7.8,15.2,7.8V60.8C789,39.9,771.5,22.9,750.2,22.9z M750.2,81.8c-11.9,0-21.5-9.4-21.5-21s9.6-21,21.5-21s21.4,9.4,21.4,21S762,81.8,750.2,81.8z M441.8,22.9c-21.3,0-38.7,17-38.7,37.9s17.3,37.9,38.7,37.9c8.9,0,17-2.9,23.5-7.8c4,6.6,15.2,7.8,15.2,7.8V60.8C480.4,39.9,463.1,22.9,441.8,22.9z M441.8,81.8c-11.8,0-21.4-9.4-21.4-21s9.6-21,21.4-21s21.5,9.4,21.5,21S453.5,81.8,441.8,81.8z M526.6,22.9c4.9,0,9.6,0.9,13.9,2.5v20c-3.7-3.2-8.6-5.1-13.9-5.1c-11.7,0-21.1,9.3-21.1,20.7v37.7h-17.8V29.9c0,0,7.3,0.3,12,3.6C506.8,27,516.2,22.8,526.6,22.9 M622,60.7c-0.1,21-16.7,38-37,38s-37-17-37.1-38V28.3c0,0,17.6,4.4,17.8,23.4v8.8c0,11.5,8.6,20.8,19.2,20.8c10.6,0,19.2-9.3,19.2-20.8V42.1c0.7-15.8,14-20.9,17.4-22.5h0.4L622,60.7L622,60.7z"/> +</svg> diff --git a/frontend/src/lib/components/icons/BRAND_COLORS.md b/frontend/src/lib/components/icons/BRAND_COLORS.md index c15537b6e1..8c8d668195 100644 --- a/frontend/src/lib/components/icons/BRAND_COLORS.md +++ b/frontend/src/lib/components/icons/BRAND_COLORS.md @@ -152,7 +152,7 @@ repeatedly — check the brand's own page. | `GoogleCloudIcon` | `gcloud`, `gcp_service_account` | fixed | #EA4335 | #EA4335 | 3.80 | 7.30 | Google's own product logo asset https://www.gstatic.com/images/branding/product/2x/google_cloud_64dp.png | | `GoogleDriveIcon` | — | fixed | #B43333 | #B43333 | 5.87 | 10.05 | https://www.gstatic.com/images/branding/productlogos/drive_2026/v2/web/192px.svg (Drive 2026 mark, copied verbatim) | | `GoogleFormsIcon` | `gforms` | fixed | #969DFF | #969DFF | 5.99 | 12.47 | Google's own Forms product icon at www.gstatic.com/images/branding/productlogos/forms_2026/v2/web/192px.svg | -| `GoogleIcon` | `google`, `gworkspace` | mixed | #4285F4 | #4285F4 | 3.88 | 7.30 | the G mark Google serves in accounts.google.com/gsi/client | +| `GoogleIcon` | `google`, `gworkspace`, `google_service_account` | mixed | #4285F4 | #4285F4 | 3.88 | 7.30 | the G mark Google serves in accounts.google.com/gsi/client | | `GorgiasIcon` | `gorgias` | pair | #000000 | #FFF9F4 | 20.32 | 11.94 | gorgias.com/about-us/style, which ships the symbol as a "Dark"/"Light" pair | | `GraphqlIcon` | `graphql` | pair | #E10098 | #FFFFFF | 4.37 | 12.47 | graphql.org | | `GreipIcon` | `greip` | pair | #141C27 | #FFFFFF | 16.59 | 12.47 | docs.greip.io | diff --git a/frontend/src/lib/components/icons/EricssonIcon.svelte b/frontend/src/lib/components/icons/EricssonIcon.svelte new file mode 100644 index 0000000000..2af894682c --- /dev/null +++ b/frontend/src/lib/components/icons/EricssonIcon.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="0 0 24 24" fill="#0082F0" xmlns="http://www.w3.org/2000/svg"> +<path d="M20.76 1.593A2.36 2.36 0 0 0 19.572.225c-.54-.27-1.188-.336-2.256.02L5.187 4.29c-1.068.357-1.548.795-1.818 1.338a2.36 2.36 0 0 0 1.059 3.174c.54.27 1.188.336 2.256-.021l12.129-4.044c1.068-.354 1.548-.795 1.818-1.338a2.35 2.35 0 0 0 .13-1.806zm0 7.485a2.36 2.36 0 0 0-1.188-1.368c-.54-.27-1.188-.336-2.256.021L5.187 11.775c-1.068.357-1.548.795-1.818 1.338a2.36 2.36 0 0 0 1.059 3.174c.54.27 1.188.336 2.256-.021l12.129-4.041c1.068-.357 1.548-.795 1.818-1.341a2.35 2.35 0 0 0 .13-1.806zm0 7.488a2.36 2.36 0 0 0-1.188-1.368c-.54-.27-1.188-.336-2.256.021L5.187 19.263c-1.068.357-1.548.795-1.818 1.338a2.36 2.36 0 0 0 1.059 3.174c.54.27 1.188.336 2.256-.02l12.129-4.045c1.068-.354 1.548-.795 1.818-1.338a2.35 2.35 0 0 0 .13-1.806z"/> +</svg> diff --git a/frontend/src/lib/components/icons/F5Icon.svelte b/frontend/src/lib/components/icons/F5Icon.svelte new file mode 100644 index 0000000000..0eec3f2759 --- /dev/null +++ b/frontend/src/lib/components/icons/F5Icon.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="0 0 24 24" fill="#E4002B" xmlns="http://www.w3.org/2000/svg"> +<path d="M12 0C5.373 0 0 5.373 0 12a11.943 11.943 0 002.336 7.1113c.509.004.8594-.111.8984-.33.046-.229.0068-.5825-.0332-.9395a55.067 55.067 0 01-.2344-7.4473c-.609.027-1.1547.055-1.6777.086.02-.471.046-.9188.084-1.3828.517-.05 1.064-.0936 1.666-.1446.026-.406.0568-.7983.0918-1.1953.27-2.43 2.828-3.9162 4.959-4.4902.946-.23 1.5245-.3022 1.9785-.3262.164-.005.3405-.0117.5175-.0117.442 0 .8899.0414 1.1739.2344.46.345.9135.6873 1.3965 1.0683.048.065.1024.1678-.0176.3438-.222.26-.4371.5084-.6621.7754-.13.157-.3454.1164-.5274.0664-.38-.194-.7462-.3728-1.1172-.5528-.672-.299-1.3666-.6061-2.1406-.5761-.483.039-.951.532-1 1.209a101.41 101.41 0 00-.1504 3.2285c1.343-.038 2.6837-.0624 4.0957-.0684l-.002.9453c-.46.206-.8954.413-1.3574.623-.953.011-1.8595.0202-2.7715.0352a125.13 125.13 0 00.1192 7.9317c.024.378.0424.7605.1504 1.0175.13.322.8798.5701 2.5098.6621.007.284.0153.5532.0253.8282-2.655-.077-5.205-.3302-7.248-.6992A11.962 11.962 0 0012 24c6.628 0 12-5.373 12-12a11.942 11.942 0 00-2.0957-6.7754c-.147.607-.2252 1.2378-.3672 1.8828-1.8-.234-3.9131-.4053-6.2871-.4883-.191.602-.3711 1.192-.5781 1.836 3.973.245 5.9048 1.2924 7.0508 2.5254 1.113 1.248 1.3501 2.6262 1.2851 3.9062-.143 2.081-1.0613 3.3971-2.3203 4.3711-1.274.96-2.8139 1.436-4.0469 1.539-1.819.137-4.2515-.2962-4.7695-.6132a178.03 178.03 0 01-.9492-2.2012c-.08-.166-.1294-.3371.0976-.5351.354-.339.6928-.6667 1.0508-1.0137.158-.155.3338-.2991.4668-.0781.489.755.9473 1.4477 1.4063 2.1367.522.77 1.3167 1.4695 3.0527 1.3535 1.459-.13 2.5675-1.2333 2.6855-2.4473.128-2.246-2.1446-3.8396-8.0546-4.3496a2571.27 2571.27 0 013.123-9.371c1.404.065 2.7043.1798 3.9453.3398.919.116 1.772.3287 2.627.4277A11.973 11.973 0 0012 0zm10.0195 21.1113c-.5006 0-.9082.4076-.9082.9082 0 .5007.4076.9082.9082.9082.5007 0 .9082-.4075.9082-.9082 0-.5006-.4075-.9082-.9082-.9082zm0 .127c.4318 0 .7793.3495.7793.7812a.7776.7776 0 01-.7793.7793c-.4317 0-.7812-.3475-.7812-.7793a.7809.7809 0 01.7812-.7812zm-.3652.2773v.9961h.1738v-.3926h.1387c.092 0 .1583.0112.1953.0332.062.037.0938.1146.0938.2286v.08l.002.0293a.07.07 0 01.0039.0118c0 .005-.0001.0068.0039.0098h.162l-.0058-.0137a.106.106 0 01-.0078-.0488.6846.6846 0 01-.004-.0743v-.0742a.276.276 0 00-.0527-.1543c-.037-.053-.0948-.0846-.1718-.0976A.408.408 0 0022.328 22c.066-.042.0977-.1103.0977-.1973 0-.124-.0503-.21-.1543-.252a.752.752 0 00-.2695-.035zm.1738.123h.1504a.45.45 0 01.211.0372c.042.024.0664.0735.0664.1445a.153.153 0 01-.1036.1563.451.451 0 01-.166.0214h-.1582z"/> +</svg> diff --git a/frontend/src/lib/components/icons/FortinetIcon.svelte b/frontend/src/lib/components/icons/FortinetIcon.svelte new file mode 100644 index 0000000000..3baddf3bc0 --- /dev/null +++ b/frontend/src/lib/components/icons/FortinetIcon.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="0 0 24 24" fill="#EE3124" xmlns="http://www.w3.org/2000/svg"> +<path d="M0 9.785h6.788v4.454H0zm8.666-6.33h6.668v4.453H8.666zm0 12.637h6.668v4.454H8.666zm8.522-6.307H24v4.454h-6.812zM2.792 3.455C1.372 3.814.265 5.404 0 7.425v.506h6.788V3.454zM0 16.091v.554c.24 1.926 1.276 3.466 2.624 3.9h4.188v-4.454zm24-8.184v-.506c-.265-1.998-1.372-3.587-2.792-3.972h-4.02v4.454H24zM21.376 20.57c1.324-.458 2.36-1.974 2.624-3.9v-.554h-6.812v4.454Z"/> +</svg> diff --git a/frontend/src/lib/components/icons/InfobloxIcon.svelte b/frontend/src/lib/components/icons/InfobloxIcon.svelte new file mode 100644 index 0000000000..c189d62291 --- /dev/null +++ b/frontend/src/lib/components/icons/InfobloxIcon.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="-9.9 0.1 29 29" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8.97208 4.65211L5.46541 8.12151C5.25799 8.32673 4.92169 8.32673 4.71427 8.12151L1.20757 4.65211C1.00015 4.44689 1.00015 4.11417 1.20757 3.90895L4.71424 0.43956C4.92167 0.234339 5.25796 0.234339 5.46539 0.43956L8.97206 3.90895C9.17951 4.11417 9.17951 4.44689 8.97208 4.65211Z" fill="#00BD4D"/> +<path d="M5.11227 28.8859H0.531141C0.237804 28.8859 0 28.6506 0 28.3604V14.2856C0 14.0028 0.115236 13.7319 0.31964 13.5341L4.74094 9.25725C5.07724 8.93195 5.64342 9.16766 5.64342 9.633V28.3604C5.64342 28.6506 5.40561 28.8859 5.11227 28.8859Z" fill="currentColor"/> +</svg> diff --git a/frontend/src/lib/components/icons/IpFabricIcon.svelte b/frontend/src/lib/components/icons/IpFabricIcon.svelte new file mode 100644 index 0000000000..c8cb80ee29 --- /dev/null +++ b/frontend/src/lib/components/icons/IpFabricIcon.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="0 0 340.2 340.2" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M254.9,227.1c0,17.2-15.8,30.6-33.7,26.5c-9.9-2.2-17.9-10.2-20.2-20.1c-4.1-17.9,9.4-33.8,26.6-33.8C242.6,199.7,254.9,212,254.9,227.1L254.9,227.1L254.9,227.1z"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M140.1,113.2c0,17.2-15.9,30.6-33.8,26.5c-9.8-2.2-17.9-10.2-20.1-20.1c-4.1-17.9,9.4-33.8,26.6-33.8C127.8,85.9,140.1,98.1,140.1,113.2L140.1,113.2L140.1,113.2z"/> +<path d="M339.9,27.6v285c0,15.1-12.2,27.2-27.3,27.2H142.2l56.6-56.5c8.6,4.4,18.4,6.9,28.7,6.9c15.2,0,29.1-5.3,40-14.2c3.2-2.6,6.2-5.6,8.9-8.8c10.9-13.1,16.5-30.7,13.8-49.6c-3.9-26.7-23.9-47.7-50.5-52.7c-15.1-2.9-29.4-0.2-41.5,6L169,141.8c4.4-8.6,6.9-18.3,6.9-28.6s-2.7-20.7-7.3-29.6l83.5-83.3h60.5C327.6,0.3,339.9,12.5,339.9,27.6z"/> +<path d="M164.2,227c0,10.3,2.5,20,6.9,28.6l-84.3,84.2H27.6c-2.1,0-4.2-0.2-6.4-0.7c-9.9-2.2-17.9-10.2-20.2-20c-0.5-2.2-0.7-4.4-0.7-6.5v-285c0-15.1,12.2-27.3,27.3-27.3h169.1l-56.2,56.1c-8.4-4.2-17.8-6.5-27.9-6.5c-15.2,0-29.1,5.3-39.9,14.2h0c-3.2,2.6-6.2,5.6-8.8,8.8c-1.8,2.1-1.8,2.1,0,0c-11.7,14.2-17.4,33.5-13,54c5.1,24.3,24.4,43.2,48.7,48.1c15.2,3.1,29.6,0.6,41.8-5.6l29.6,29.5C166.6,207.4,164.2,216.9,164.2,227L164.2,227z"/> +</svg> diff --git a/frontend/src/lib/components/icons/NetskopeIcon.svelte b/frontend/src/lib/components/icons/NetskopeIcon.svelte new file mode 100644 index 0000000000..7d5fa68895 --- /dev/null +++ b/frontend/src/lib/components/icons/NetskopeIcon.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="0 0 76.751 76.751" xmlns="http://www.w3.org/2000/svg"> +<path fill="#FF8300" d="M18.840 32.706C18.393 33.758 17.751 34.750 16.852 35.648C14.869 37.631 12.440 38.371 9.898 38.502L19.452 48.057C19.582 45.514 20.324 43.085 22.305 41.102C23.205 40.203 24.196 39.562 25.248 39.114C22.284 38.024 19.931 35.671 18.840 32.706Z"/> +<path fill="#FF8300" d="M16.739 54.333C20.361 50.711 20.361 44.838 16.739 41.216C13.117 37.594 7.244 37.594 3.622 41.216C0.000 44.838 0.000 50.711 3.622 54.333C7.244 57.955 13.117 57.955 16.739 54.333Z"/> +<path fill="#54555A" d="M35.536 22.419C39.158 26.041 39.158 31.914 35.536 35.536C31.914 39.158 26.041 39.158 22.419 35.536C18.797 31.914 18.797 26.041 22.419 22.419C26.041 18.797 31.914 18.797 35.536 22.419Z"/> +<path fill="#00A6CE" d="M69.924 32.330C69.028 33.226 67.837 33.719 66.571 33.719C65.305 33.719 64.114 33.226 63.218 32.330C61.369 30.481 61.369 27.473 63.218 25.625C64.114 24.729 65.305 24.235 66.571 24.235C67.837 24.235 69.028 24.729 69.924 25.625C70.819 26.520 71.313 27.711 71.313 28.977C71.313 30.244 70.819 31.434 69.924 32.330M73.129 22.419C69.507 18.797 63.634 18.797 60.013 22.419C58.272 24.158 57.371 26.417 57.302 28.697L57.300 28.695C57.169 31.237 56.428 33.666 54.445 35.649C53.545 36.550 52.551 37.192 51.496 37.639C54.463 38.729 56.819 41.085 57.909 44.051C58.356 42.997 58.998 42.003 59.899 41.103C61.882 39.120 64.312 38.379 66.854 38.248L66.851 38.246C69.131 38.177 71.390 37.276 73.129 35.536C76.751 31.914 76.751 26.041 73.129 22.419Z"/> +<path fill="#54555A" d="M54.332 41.215C57.954 44.837 57.954 50.710 54.332 54.332C50.710 57.954 44.837 57.954 41.215 54.332C37.593 50.710 37.593 44.837 41.215 41.215C44.837 37.593 50.710 37.593 54.332 41.215Z"/> +<path fill="#54555A" d="M41.102 35.649C39.120 33.667 38.379 31.237 38.248 28.695L28.694 38.248C31.236 38.379 33.666 39.120 35.649 41.102C37.631 43.085 38.372 45.514 38.503 48.057L48.057 38.503C45.514 38.373 43.084 37.632 41.102 35.649Z"/> +</svg> diff --git a/frontend/src/lib/components/icons/PaloAltoIcon.svelte b/frontend/src/lib/components/icons/PaloAltoIcon.svelte new file mode 100644 index 0000000000..c43173b8fd --- /dev/null +++ b/frontend/src/lib/components/icons/PaloAltoIcon.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="0 0 24 24" fill="#F04E23" xmlns="http://www.w3.org/2000/svg"> +<path d="m10.278 15.443 1.705 1.705-3.426 3.426-3.427-3.426 8.592-8.591-1.705-1.705 3.426-3.426 3.427 3.426-8.592 8.591zM0 12.017l3.426 3.426 8.591-8.59-3.426-3.427L0 12.017zm11.983 5.13 3.426 3.427L24 11.983l-3.426-3.426-8.591 8.59z"/> +</svg> diff --git a/frontend/src/lib/components/icons/SplunkIcon.svelte b/frontend/src/lib/components/icons/SplunkIcon.svelte new file mode 100644 index 0000000000..05d3ac6dfb --- /dev/null +++ b/frontend/src/lib/components/icons/SplunkIcon.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> +<path d="M23.348 11.911l-2.241-1.091v-.65L24 11.621v.593l-2.893 1.438v-.636zm-5.397 1.841h-.961v-5.31h.961v3.116h.102l1.28-1.481.723.31-1.23 1.316 1.453 1.809-.888.311-1.44-1.996zm-2.577-.002v-2.068a2.685 2.685 0 0 0-.026-.42.791.791 0 0 0-.09-.26c-.113-.202-.308-.304-.59-.304a.888.888 0 0 0-.461.113.673.673 0 0 0-.286.33 1.012 1.012 0 0 0-.07.263c-.012.13-.019.262-.017.395v1.95h-.961v-3.614h.961l.002.485c.185-.2.373-.348.566-.437.192-.089.418-.134.673-.134.286 0 .527.058.721.177a1.016 1.016 0 0 1 .475.665 1.972 1.972 0 0 1 .054.448c.002.1.004.22.004.358v2.053zm-4.115.002l-.002-.485a1.783 1.783 0 0 1-.565.437 1.597 1.597 0 0 1-.674.135c-.285 0-.524-.057-.72-.17a.972.972 0 0 1-.425-.504.75.75 0 0 1-.054-.167 1.918 1.918 0 0 1-.033-.199 2.033 2.033 0 0 1-.017-.258 15.516 15.516 0 0 1-.005-.355V10.13h.956v2.07c-.003.141.006.282.026.42.015.092.045.18.09.26.113.204.308.306.59.306.36 0 .606-.15.74-.449.035-.082.06-.168.074-.257.017-.134.024-.269.022-.403v-1.95h.955v3.624zM7.184 8.44h.955v5.31h-.955zM5.759 11.9c0-.396-.08-.708-.24-.937a.759.759 0 0 0-.657-.345.804.804 0 0 0-.693.366c-.171.245-.256.574-.253.99 0 .405.084.723.25.957a.796.796 0 0 0 .69.347.685.685 0 0 0 .433-.135.985.985 0 0 0 .277-.34c.071-.14.121-.292.147-.448.03-.151.043-.3.046-.455m1.01-.036c.003.266-.04.532-.129.786-.082.23-.204.441-.364.626-.31.361-.764.567-1.24.563a1.67 1.67 0 0 1-.313-.028 1.041 1.041 0 0 1-.275-.098 1.33 1.33 0 0 1-.257-.178 2.379 2.379 0 0 1-.265-.268v2.293h-.929v-5.425h.93l.004.529c.169-.212.353-.368.55-.468.197-.1.426-.15.688-.147a1.509 1.509 0 0 1 1.156.507c.148.166.259.361.33.571.08.236.12.485.115.737m-4.21.89a.946.946 0 0 1-.102.441 1.007 1.007 0 0 1-.282.345c-.13.1-.275.173-.43.22a1.8 1.8 0 0 1-.546.08 1.985 1.985 0 0 1-.637-.097 1.964 1.964 0 0 1-.563-.32l.312-.505c.15.126.284.217.405.275.115.057.24.087.368.087a.557.557 0 0 0 .373-.12.396.396 0 0 0 .14-.322.475.475 0 0 0-.12-.318 1.306 1.306 0 0 0-.187-.173 9.231 9.231 0 0 0-.308-.232 6.787 6.787 0 0 1-.281-.21 2.11 2.11 0 0 1-.252-.232 1.039 1.039 0 0 1-.18-.275.826.826 0 0 1-.069-.347.893.893 0 0 1 .094-.409.935.935 0 0 1 .255-.314 1.22 1.22 0 0 1 .39-.203c.16-.05.327-.074.494-.072.184 0 .368.026.545.076.174.05.338.123.488.219l-.282.454a1.05 1.05 0 0 0-.608-.201.504.504 0 0 0-.323.102.307.307 0 0 0-.126.253c0 .098.041.193.113.26.074.078.203.186.385.325.185.136.336.253.457.355.104.085.202.182.286.286.065.08.115.173.145.273a.808.808 0 0 1 .046.299Z"/> +</svg> diff --git a/frontend/src/lib/components/icons/TufinIcon.svelte b/frontend/src/lib/components/icons/TufinIcon.svelte new file mode 100644 index 0000000000..826aa3cd3d --- /dev/null +++ b/frontend/src/lib/components/icons/TufinIcon.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="0 0 218 78" fill="#EC7700" xmlns="http://www.w3.org/2000/svg"> +<path d="M22.499 21.0558C22.1683 20.6213 21.9983 20.0856 22.018 19.5398V3.43584L3.024 8.86384V18.1858C2.98189 19.4486 2.49773 20.6565 1.656 21.5988C1.23386 22.0971 0.645615 22.4257 0 22.5238V32.4138C0.951592 32.9494 1.70493 33.7778 2.148 34.7758C2.72621 36.0334 2.98487 37.4143 2.901 38.7958V62.8828C2.901 70.9148 5.708 76.8368 17.055 77.3808C21.101 77.4648 25.248 77.7778 29.029 76.0628V65.8698C27.4564 65.2917 26.0147 64.4061 24.788 63.2648C23.231 62.1648 22.193 59.2458 22.193 56.5818V37.6058C22.0327 36.4337 22.2666 35.2415 22.858 34.2168C23.2334 33.7957 23.6966 33.4619 24.2149 33.2391C24.7332 33.0162 25.2941 32.9096 25.858 32.9268H30.244V22.3718H25.449C24.317 22.3718 23.08 22.1818 22.499 21.0538V21.0558ZM88.843 64.2358C88.3341 62.9916 88.0438 61.6688 87.985 60.3258V21.7488H65.735V32.3328C66.2433 32.4597 66.7209 32.6878 67.1391 33.0034C67.5573 33.3191 67.9076 33.7158 68.169 34.1698C68.8231 35.1594 69.1495 36.3296 69.102 37.5148V59.3008C69.2108 60.4209 69.0432 61.5506 68.614 62.5908C68.3972 62.9241 68.1032 63.2002 67.757 63.3955C67.4107 63.5908 67.0224 63.6997 66.625 63.7128H63.484C62.691 63.7954 61.8897 63.6974 61.14 63.4262C60.3903 63.155 59.7116 62.7176 59.155 62.1468C57.21 59.9588 57.942 55.6148 57.942 52.9938V21.7498H35.186V32.3338C35.7753 32.5209 36.3315 32.7997 36.834 33.1598C37.3719 33.5768 37.8062 34.1122 38.1034 34.7245C38.4005 35.3368 38.5523 36.0093 38.547 36.6898V62.3378C38.547 78.4698 55.548 80.3098 66.079 74.6298C67.183 74.0418 69.704 73.3238 69.704 75.4998V76.5898H91.235V66.3308C90.2004 65.9618 89.3451 65.2128 88.843 64.2358ZM215.999 63.5568C215.513 62.6305 215.301 61.5853 215.386 60.5428V35.7658C215.386 19.6638 200.144 17.8668 189.778 23.6158C188.73 24.0518 186.143 24.8938 186.143 22.7458V21.2338H164.74V32.0048C165.665 32.6879 166.435 33.5585 167 34.5597C167.565 35.5609 167.913 36.6701 168.02 37.8148V59.6978C168.108 60.8478 167.889 62.0005 167.385 63.0378C167.03 63.6145 166.546 64.1007 165.971 64.4578V76.4948H189.935V64.8208C189.06 64.4602 188.302 63.8637 187.745 63.0978C186.985 62.127 186.569 60.9308 186.564 59.6978V38.9338C186.552 38.3401 186.574 37.7461 186.629 37.1548C186.636 36.6143 186.744 36.0799 186.947 35.5788C187.249 34.8088 188.446 34.5228 189.032 34.5228H192.101C192.693 34.5238 193.277 34.6682 193.801 34.9437C194.326 35.2192 194.776 35.6176 195.113 36.1048C197.054 38.2948 196.419 42.5778 196.419 45.2548V76.4928H217.536V64.8588C216.915 64.5728 216.383 64.1226 215.999 63.5568ZM115.348 0.15984C103.811 0.75284 100.301 6.89984 100.301 15.1998V17.8498C100.361 18.4639 100.296 19.0838 100.107 19.6715C99.9192 20.2591 99.6128 20.8021 99.207 21.2668C98.8647 21.6904 98.3862 21.9823 97.853 22.0928V32.3078C98.7411 32.8627 99.4323 33.6827 99.829 34.6518C100.499 36.0118 100.301 37.2848 100.301 38.7048V59.9048C100.301 61.3468 100.517 62.6978 99.531 63.9328C99.1144 64.4491 98.5778 64.8556 97.968 65.1168V76.4928H122.838V65.2128C122.039 64.8667 121.346 64.3149 120.83 63.6138C120.113 62.5163 119.78 61.2117 119.885 59.9048L119.792 37.5148C119.792 36.2008 119.585 35.1958 120.457 34.0808C120.844 33.6739 121.316 33.3565 121.838 33.1509C122.361 32.9453 122.922 32.8566 123.483 32.8908H126.004V21.9578H123.483C122.92 22.0249 122.35 21.9565 121.819 21.7582C121.288 21.5598 120.812 21.2374 120.431 20.8178C120.211 20.6043 120.04 20.3457 119.93 20.0599C119.82 19.774 119.773 19.4676 119.792 19.1618V17.6528C119.721 16.7139 119.875 15.7715 120.242 14.9043C120.609 14.0371 121.178 13.2703 121.902 12.6678C123.542 11.4376 125.588 10.8764 127.626 11.0978V1.13984C123.699 -0.40316 119.496 0.0178396 115.348 0.15984ZM157.548 63.3828C156.775 62.3832 156.333 61.1669 156.284 59.9038V21.5538H133.944V32.3188C134.648 32.743 135.237 33.335 135.657 34.0418C136.618 35.1805 137.164 36.6108 137.208 38.0998V59.9038C137.315 61.0796 137.102 62.2623 136.591 63.3268C136.226 63.8912 135.746 64.3726 135.183 64.7398V76.4938H158.962V64.7398C158.392 64.4034 157.908 63.9388 157.548 63.3828Z"/> +</svg> diff --git a/frontend/src/lib/components/icons/ZabbixIcon.svelte b/frontend/src/lib/components/icons/ZabbixIcon.svelte new file mode 100644 index 0000000000..565ca1d856 --- /dev/null +++ b/frontend/src/lib/components/icons/ZabbixIcon.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() +</script> + +<svg {width} {height} viewBox="0 3.4 29.2 29.2" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M136.977 0.0457153H0V36H136.977V0.0457153Z" fill="#D40000"/> +<path d="M5.45605 30.5143V27.4057L19.0853 8.89143H5.45605V5.48572H23.7425V8.59429L10.1133 27.1086H23.7425V30.5143H5.45605Z" fill="white"/> +</svg> diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index c8f4cd8f38..19fae85f20 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -322,6 +322,17 @@ import LeonardoAiIcon from './LeonardoAiIcon.svelte' import LocalContextsIcon from './LocalContextsIcon.svelte' import WeatherApiIcon from './WeatherApiIcon.svelte' import WebScrapingAiIcon from './WebScrapingAiIcon.svelte' +import ArubaIcon from './ArubaIcon.svelte' +import F5Icon from './F5Icon.svelte' +import FortinetIcon from './FortinetIcon.svelte' +import InfobloxIcon from './InfobloxIcon.svelte' +import IpFabricIcon from './IpFabricIcon.svelte' +import NetskopeIcon from './NetskopeIcon.svelte' +import PaloAltoIcon from './PaloAltoIcon.svelte' +import SplunkIcon from './SplunkIcon.svelte' +import TufinIcon from './TufinIcon.svelte' +import ZabbixIcon from './ZabbixIcon.svelte' +import EricssonIcon from './EricssonIcon.svelte' import type { Component } from 'svelte' /** * ./triggers/ holds desaturated variants of some marks in this folder, used by the four @@ -361,7 +372,8 @@ export const APP_TO_ICON_COMPONENT = { gsheets: GSheetsIcon, gitlab: GitlabIcon, // `gcloud` and `gcp_service_account` are both Google Cloud, so both resolve to the - // official four-colour mark. + // official four-colour mark. `google_service_account` also serves Workspace APIs, + // so it takes the G mark like `gworkspace`. gcloud: GoogleCloudIcon, gcal: GcalIcon, gdrive: GdriveIcon, @@ -460,6 +472,7 @@ export const APP_TO_ICON_COMPONENT = { ipinfo: IpinfoIcon, gworkspace: GoogleIcon, gcp_service_account: GoogleCloudIcon, + google_service_account: GoogleIcon, cacertificate: CaCertificate, currencyapi: CurrencyApiIcon, visma: VismaIcon, @@ -687,6 +700,20 @@ export const APP_TO_ICON_COMPONENT = { localcontexts: LocalContextsIcon, weatherapi: WeatherApiIcon, webscrapingai: WebScrapingAiIcon, + aruba_central_onprem: ArubaIcon, + aruba_instant: ArubaIcon, + awx: AnsibleIcon, + f5_bigip: F5Icon, + fortimanager: FortinetIcon, + fortigate: FortinetIcon, + infoblox: InfobloxIcon, + ip_fabric: IpFabricIcon, + netskope: NetskopeIcon, + palo_alto: PaloAltoIcon, + splunk: SplunkIcon, + tufin: TufinIcon, + zabbix: ZabbixIcon, + ericsson_ep5g: EricssonIcon, oauth_client_credentials: OauthIcon } as unknown as Record<string, Component> // to generate correct svelte package types @@ -1008,5 +1035,16 @@ export { DbtIcon, ShutterstockIcon, TaskadeIcon, + ArubaIcon, + F5Icon, + FortinetIcon, + InfobloxIcon, + IpFabricIcon, + NetskopeIcon, + PaloAltoIcon, + SplunkIcon, + TufinIcon, + ZabbixIcon, + EricssonIcon, OauthIcon } diff --git a/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte b/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte index d55c2f9ce6..cde5e340f3 100644 --- a/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte +++ b/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte @@ -3,10 +3,8 @@ import Head from '$lib/components/table/Head.svelte' import Cell from '$lib/components/table/Cell.svelte' import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' - import Toggle from '$lib/components/Toggle.svelte' import { Alert } from '$lib/components/common' - import { SettingService, type GuestActivity, type GuestUsage } from '$lib/gen' - import { sendUserToast } from '$lib/toast' + import type { GuestActivity, GuestUsage } from '$lib/gen' interface Props { usage: GuestUsage @@ -14,44 +12,10 @@ hasMore: boolean loading: boolean onLoadMore: () => void - /** The instance switch was written; the caller re-reads usage and says whether - * that read succeeded, so the toggle can show what is actually stored. */ - onInstanceSwitch: () => Promise<boolean> } - let { usage, guests, hasMore, loading, onLoadMore, onInstanceSwitch }: Props = $props() + let { usage, guests, hasMore, loading, onLoadMore }: Props = $props() const loadMoreSize = 50 - // One write at a time, and the toggle always ends on what is stored: the reloaded - // value when the reload succeeds, else the write's outcome. - let switchPending = $state(false) - let switchOn = $state(usage.instance_enabled) - $effect(() => { - switchOn = usage.instance_enabled - }) - - async function setInstanceSwitch(enabled: boolean) { - switchPending = true - let written = false - try { - await SettingService.setGlobal({ - key: 'guest_access_disabled', - requestBody: { value: !enabled } - }) - written = true - sendUserToast( - enabled - ? 'Guests can sign in again where a workspace allows them' - : 'Guests can no longer sign in anywhere on this instance' - ) - } catch (e) { - sendUserToast(`Could not change the instance guest switch: ${e}`, true) - } - const reloaded = await onInstanceSwitch() - if (!reloaded) { - switchOn = written ? enabled : usage.instance_enabled - } - switchPending = false - } // A capped instance refuses the next stranger as soon as the allowance is used up. let pastAllowance = $derived( usage.metered @@ -68,26 +32,11 @@ {#if !usage.available} <div class="mb-4"> <Alert type="info" size="xs" title="Guests are not available on Windmill Cloud"> - No guest can sign in here, whatever a workspace or an app says. Guests require a self-hosted - instance or a dedicated Windmill Cloud deployment. + No guest can sign in here, whatever an app says. Guests require a self-hosted instance or a + dedicated Windmill Cloud deployment. </Alert> </div> {:else} - <div class="flex flex-row gap-2 items-center mb-4"> - {#key usage} - <Toggle - bind:checked={switchOn} - disabled={switchPending} - on:change={(e) => setInstanceSwitch(e.detail)} - options={{ - right: 'Allow guests on this instance', - rightTooltip: - 'Off, no guest can sign in anywhere, whatever a workspace or an app says, and sessions already issued stop on their next request.' - }} - /> - {/key} - </div> - <div class="mb-4"> <Alert type={pastAllowance ? 'warning' : 'info'} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index a03d5512ca..e006e25265 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -604,7 +604,10 @@ // reintroduce the post-deploy noise the head side already strips. return replaceFalseWithUndefined(stripRawAppDiffNoise(v as any)) }, - current: currentDiffValue + current: currentDiffValue, + aiReview: opWorkspace + ? { workspaceId: opWorkspace, kind: 'raw_app', path: appPath } + : undefined }, opening ) @@ -870,6 +873,9 @@ mode: 'normal', deployed: deployedValue ?? stripRawAppDiffNoise(savedApp), current: currentDiffValue, + aiReview: opWorkspace + ? { workspaceId: opWorkspace, kind: 'raw_app', path: appPath } + : undefined, button: { text: 'Looks good, deploy', onClick: () => { diff --git a/frontend/src/lib/components/raw_apps/templates.ts b/frontend/src/lib/components/raw_apps/templates.ts index e54f89fdfa..49ddce7d90 100644 --- a/frontend/src/lib/components/raw_apps/templates.ts +++ b/frontend/src/lib/components/raw_apps/templates.ts @@ -516,7 +516,7 @@ const app = mount(App, { target: document.getElementById("root")! }); export default app; ` -const indexCss = `/* The app renders inside an iframe whose host page has its own theme the +export const themeCss = `/* The app renders inside an iframe whose host page has its own theme the iframe cannot read, so paint an opaque surface here: without one the host's background shows through and dark text lands on it unreadable. */ :root { @@ -575,7 +575,9 @@ body { line-height: 1.5; -webkit-font-smoothing: antialiased; } +` +const indexCss = `${themeCss} h1, h2 { margin: 0; diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index 93e62702f8..cd9772a331 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -21,8 +21,11 @@ previewLocationLabel, barePath } from './previewRouter' - import { useSessionPermissions } from './sessionPermissions.svelte' + import { useSessionPermissions, useSessionReadOnly } from './sessionPermissions.svelte' import { agentWriteCount } from '$lib/components/flows/agentEditorStore.svelte' + import { userWorkspaces } from '$lib/stores' + import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy' + import { devLabelNoun } from '$lib/utils/devWorkspaceLabel' let { runtime, @@ -70,6 +73,8 @@ () => runtime.manager ) const canEdit = $derived(permissions.canOpenEditor(kind)) + const protectedWorkspace = useSessionReadOnly(() => workspaceId) + const devWorkspace = $derived(findCanonicalDevWorkspace(workspaceId, $userWorkspaces)) // This reads the deployed version over the API rather than from the editor cell the // chat mutates, so it is the one preview kind that does not self-sync (the invariant @@ -259,7 +264,11 @@ <div class="text-xs text-tertiary max-w-sm"> Nothing is deployed at <span class="font-mono">{path}</span>, so there is no deployed version to view.{#if canEdit}{' '} - Deploy it from the editor to see it here.{/if} + Deploy it from the editor to see it here.{:else if protectedWorkspace.confirmed}{' '} + Direct deployment is disabled in this workspace, so this session can read and run deployed + items, but not edit. Pick {devWorkspace + ? `its ${devLabelNoun(devWorkspace.dev_workspace_label)}` + : 'a fork'} to make changes.{/if} </div> {#if canEdit} <Button unifiedSize="sm" variant="default" startIcon={{ icon: Pen }} on:click={toEditSide}> diff --git a/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte b/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte index 359c73c998..e8c6da3016 100644 --- a/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte +++ b/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte @@ -168,21 +168,35 @@ section or the flat layout. // saying "show me this", so it lands on the deployed page, and `navigate` // would otherwise keep whichever side the re-pointed tab happened to be on. // A draft-only item opens its editor, unless this user cannot save there. + function itemTarget(item: WorkspaceItem): PreviewTarget { + return { type: 'item', item, mode: canEdit(item) ? previewModeFor(item) : 'view' } + } function tagItems(nodes: DrillNode<WorkspaceItem>[]): DrillNode<PreviewTarget>[] { return nodes.map((n) => n.type === 'leaf' - ? { - ...n, - data: { - type: 'item', - item: n.data, - mode: canEdit(n.data) ? previewModeFor(n.data) : 'view' - } - } + ? { ...n, data: itemTarget(n.data) } : { ...n, children: tagItems(n.children) } ) } + // The mode is saved with the tab and never re-checked, and `canEdit` refuses until the + // rights and protection rules arrive: a draft-only item picked before then would open in a + // viewer that has nothing to show. Hold the pick until both have answered. + let heldItem = $state<WorkspaceItem | undefined>(undefined) + $effect(() => { + if (!heldItem || !permissions.settled) return + const item = heldItem + heldItem = undefined + untrack(() => onPick(itemTarget(item))) + }) + function pick(target: PreviewTarget) { + if (target.type === 'item' && target.item.draftOnly && !permissions.settled) { + heldItem = target.item + } else { + onPick(target) + } + } + // Open the item's editor. Offered on every item row this user can edit, including the // draft-only ones a plain click already opens there: a row that drops the button to say // "you were getting this anyway" reads as one that cannot be edited. @@ -392,7 +406,7 @@ section or the flat layout. <DrillPicker bind:this={inner} {tree} - onPick={(leaf) => onPick(leaf.data)} + onPick={(leaf) => pick(leaf.data)} initialScope={computedInitialScope} {initialHighlight} {externalFilter} diff --git a/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte b/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte index 8434aa6f74..6030a9c4ef 100644 --- a/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte @@ -102,6 +102,7 @@ iconKindFor={(item) => (isAgent(item) ? 'agent' : undefined)} {compareSessionHref} workspaceLabel={ws?.name ?? workspaceId} + reviewWorkspaceId={workspaceId} > {#snippet titleExtra()} <div class="flex items-center gap-1.5 text-xs text-secondary min-w-0"> diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 6f751471ff..f835ed861a 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -112,8 +112,10 @@ setContext<AIChatManager>('aiChatManager', runtime.manager) } - // Reactive session reference (mutations to summary/target propagate via the $state proxy) - const session = $derived(sessionState.sessions.find((s) => s.id === sessionId)) + // Reactive session reference (mutations to summary/target propagate via the $state proxy). + // Falls back to the mounted session once it is deleted: the layout's header reads this + // through PageHeaderContent, outside the page's tree, once more before the wrapper unmounts. + const session = $derived(sessionState.sessions.find((s) => s.id === sessionId) ?? initialSession) // Only a session that has never sent opens as the starter block. Read off the record, not // the transcript: a cold session's history is still loading from storage when it mounts, diff --git a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte index c308902d4d..bf1d5ef2b4 100644 --- a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte @@ -42,6 +42,9 @@ import { maskKey } from './modifiedItemsMask' import type { SessionDeployModel, DiffValues } from './sessionDeployModel.svelte' import { SIDE_BY_SIDE_MIN_WIDTH } from '$lib/components/diffEditorTypes' + import { copilotInfo } from '$lib/aiStore' + import AiReview from '$lib/components/deploymentRequest/AiReview.svelte' + import type { ReviewItem } from '$lib/components/deploymentRequest/aiReview' // The session "Edits" surface. Reads a unified item model (the session's // drafts, scoped to what this session edited) and renders a folder tree @@ -56,6 +59,7 @@ titleExtra, compareSessionHref, workspaceLabel, + reviewWorkspaceId, successHoldMs = 1200 }: { model: SessionDeployModel @@ -73,8 +77,23 @@ /** Compare page for this session's edits (footer) — preselected via * `from_session`. Where "deploy all / request review" happens. */ compareSessionHref?: string + /** The workspace the items live in; when set, the pending drafts can be reviewed with AI. */ + reviewWorkspaceId?: string } = $props() + // Your own pending drafts, read through the same loader as the diff column. + const reviewItems: ReviewItem[] = $derived( + model.items + .filter((i) => i.hasDraft && !i.done && i.mine) + .map((i) => ({ + kind: i.draftKind, + path: i.path, + change: i.draftOnly ? 'added' : 'modified', + load: () => model.loadDiffValues(i) + })) + ) + const showReview = $derived(!!reviewWorkspaceId && $copilotInfo.enabled && model.items.length > 0) + let drawer: Drawer | undefined = $state(undefined) let searchQuery = $state('') // The user's explicit view choice. The *effective* view can still be inline @@ -1138,6 +1157,16 @@ </div> </main> </div> + {#if showReview && reviewWorkspaceId} + <div + class="shrink-0 border-t border-light bg-surface px-3 py-2 max-h-[40vh] overflow-y-auto" + > + <AiReview + scope={{ kind: 'drafts', workspaceId: reviewWorkspaceId, items: reviewItems }} + source="session_edits" + /> + </div> + {/if} <!-- Batch / PR (deploy all, request review, reconcile) is the compare page's job — the dock deploys per item; this footer is just the doorway there. A plain anchor (not Button href): it's a text-level navigation link, and diff --git a/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts b/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts index e2524e3c0f..c10e6215b7 100644 --- a/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts @@ -16,9 +16,12 @@ import { previewPageAllowed, type PreviewPage } from './previewRouter' /** Whether a session in `workspace` is read-only because the workspace is protected: the verdict * `resolveSessionAccess` gives the chat, so the UI and the toolset agree. */ export function useSessionReadOnly(workspace: () => string | undefined) { - const verdict = resource(workspace, async (ws) => - ws ? { workspace: ws, readOnly: await isReadOnlySessionWorkspace(ws) } : undefined - ) + const verdict = resource(workspace, async (ws) => { + if (!ws) return undefined + const readOnly = await isReadOnlySessionWorkspace(ws) + // Only a successful read settles a verdict; a failed one is read-only to fail closed. + return { workspace: ws, readOnly, rulesRead: settledReadOnlyVerdict(ws) !== undefined } + }) // `resource` keeps the previous workspace's verdict while it asks about the next one. const verdictFor = (ws: string): boolean | undefined => { const landed = verdict.current @@ -37,6 +40,21 @@ export function useSessionReadOnly(workspace: () => string | undefined) { get known() { const ws = workspace() return !!ws && (verdictFor(ws) ?? false) + }, + /** Protected by a rule that was actually read: false when the read failed, where the session + * is still read-only. For text that names the rule as the reason. */ + get confirmed() { + const ws = workspace() + if (!ws) return false + const landed = verdict.current + return landed?.workspace === ws + ? landed.readOnly && landed.rulesRead + : (settledReadOnlyVerdict(ws) ?? false) + }, + /** Whether the rules were read, a failed read included, which settles as read-only. */ + get settled() { + const ws = workspace() + return !!ws && verdictFor(ws) !== undefined } } } @@ -93,6 +111,11 @@ export function useSessionPermissions( get moveStarted() { return moveStarted }, + /** Whether `canEditItem` answers for real rather than refusing while it waits for this + * user's rights or the protection rules. */ + get settled() { + return user.resolved(workspace()) && protectedWorkspace.settled + }, /** Whether this user can save the drafts the `kind` editor writes. */ canOpenEditor: (kind: string) => !readOnly && !moving && rights.roleCanDraft(kind), /** The same, and this item's own permissions let them write it. */ diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index fe8c336bb9..88d7d08e96 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -129,6 +129,10 @@ export type Session = { // untouched draft never persists, so idle `+` clicks vanish on reload // instead of littering the sidebar. transient?: boolean + // In memory only, on a transient session opened onto an item that lives on its own (a new + // app's draft): its preview writes are not a touch, so a session the user never starts + // vanishes on reload instead of reopening onto the item. Cleared with `transient`. + untilStarted?: boolean // Epoch ms of the last write to this record — typing, renaming, archiving, // changing preview tabs, or catching up on new messages. Stamped by the two // write funnels (persistTouched, markSessionSeen), so it tracks use rather @@ -475,10 +479,17 @@ export function takeSessionAutoSend(sessionId: string): boolean { // stays in memory and vanishes on reload. function persistTouched(s: Session): void { if (s.transient) delete s.transient + delete s.untilStarted s.lastActivityAt = Date.now() void putSession(s) } +// Preview state (tabs, collapse, width) is a touch except on an `untilStarted` session. +function persistPreviewTouch(s: Session): void { + if (s.transient && s.untilStarted) return + persistTouched(s) +} + // When the session was last used. Pre-dates-the-field records report their // creation time, which is the earliest activity they could have had. export function sessionLastActivityAt(s: Session): number { @@ -1072,8 +1083,10 @@ export function requestComposerFocus(): void { // drop. `draftPrompt === undefined` (never edited), not falsiness: a draft typed // then erased to '' still has a pending flush and is a real session. Every other // touch clears `transient` synchronously, so only the draft prompt needs checking. +// An `untilStarted` session is not blank: reused it would reopen its item, and dropped +// while on screen it would leave the page showing a session that is gone. function isDiscardableDraft(s: Session): boolean { - return !!s.transient && s.draftPrompt === undefined + return !!s.transient && s.draftPrompt === undefined && !s.untilStarted } // Somewhere empty to put the user, for a URL naming a session this browser @@ -1084,6 +1097,7 @@ export function findEmptyLandingSession(): Session | undefined { return sessionState.sessions.find( (s) => !!s.transient && + !s.untilStarted && !s.archived && !s.workspace_id && // Falsiness, not `=== undefined`: we only navigate into the session, so a @@ -1194,6 +1208,7 @@ export function materializeTransient(id: string): void { const s = sessionState.sessions.find((x) => x.id === id) if (!s || !s.transient) return delete s.transient + delete s.untilStarted void putSession(s) } @@ -1214,6 +1229,12 @@ export function stopFollowingHome(id: string) { if (!s.transient) void putSession(s) } +// Keep a just-created session in memory until the user starts it (see `untilStarted`). +export function holdSessionUntilStarted(id: string) { + const s = sessionState.sessions.find((x) => x.id === id) + if (s?.transient) s.untilStarted = true +} + // Park a just-created session on a workspace. Deliberately does not persist a // still-transient draft: picking where it will live is part of creating it, not a // user touch, so an unused draft still vanishes on reload (see `transient`). @@ -1332,7 +1353,7 @@ export function setSessionTabs(id: string, tabs: SessionPreviewTab[], activeTabI if (!s) return s.previewTabs = tabs.map((t) => ({ ...t })) s.activePreviewTabId = activeTabId - persistTouched(s) + persistPreviewTouch(s) } // Persist whether the preview panel is collapsed for this session (a touch). @@ -1340,7 +1361,7 @@ export function setSessionPreviewCollapsed(id: string, collapsed: boolean): void const s = sessionState.sessions.find((x) => x.id === id) if (!s || !!s.previewCollapsed === collapsed) return s.previewCollapsed = collapsed - persistTouched(s) + persistPreviewTouch(s) } // Persist the preview split size the user dragged for this session (a touch). @@ -1348,7 +1369,7 @@ export function setSessionPreviewSize(id: string, size: number): void { const s = sessionState.sessions.find((x) => x.id === id) if (!s || s.previewSize === size) return s.previewSize = size - persistTouched(s) + persistPreviewTouch(s) } // Persist the session's Use or Build choice (a touch). Plan is left out: a reload ends the diff --git a/frontend/src/lib/components/sessions/sessionState.test.ts b/frontend/src/lib/components/sessions/sessionState.test.ts index 5dd3570c6b..d7ab1d7e62 100644 --- a/frontend/src/lib/components/sessions/sessionState.test.ts +++ b/frontend/src/lib/components/sessions/sessionState.test.ts @@ -7,6 +7,7 @@ import { decideSessionLifecycle, findEmptyLandingSession, getSessionDraftAttachments, + holdSessionUntilStarted, homeStarterSession, isForkSession, isTearingDownOpenSession, @@ -16,6 +17,7 @@ import { setGeneratedSessionSummary, setSessionDraftAttachments, setSessionDraftPrompt, + setSessionTabs, sessionState, withOpenSessionTeardown, type Session @@ -489,6 +491,36 @@ describe('createSession — reuses an untouched draft, family-scoped', () => { } }) + it('keeps a session held until started in memory, and does not reuse it', () => { + const restore = withTwoFamilies('rootA') + const prevCurrent = sessionState.currentSessionId + const held = session({ + id: 'held-on-app', + name: 'session-905', + pending_workspace_id: 'forkA', + transient: true + }) + sessionState.sessions.push(held) + let createdId: string | undefined + try { + holdSessionUntilStarted('held-on-app') + const tabs = [ + { id: 'session', url: '/apps_raw/edit/u/me/app', loc: '/apps_raw/edit/u/me/app' } + ] + setSessionTabs('held-on-app', tabs, 'session') + expect(held.transient).toBe(true) + createdId = createSession().id + expect(createdId).not.toBe('held-on-app') + expect(sessionState.sessions).toContain(held) + } finally { + sessionState.sessions = sessionState.sessions.filter( + (s) => s.id !== 'held-on-app' && s.id !== createdId + ) + sessionState.currentSessionId = prevCurrent + restore() + } + }) + it('clears the recovery notice off the draft it reuses, so `+` is not answered with "not found"', () => { const restore = withTwoFamilies('rootA') const prevCurrent = sessionState.currentSessionId diff --git a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts index 2ef63bacfc..cb61cce413 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts @@ -3,6 +3,7 @@ import { goto } from '$lib/navigation' import { workspaceStore } from '$lib/stores' import { createSession, + holdSessionUntilStarted, selectSession, sessionPageHref, sessionInCurrentFamily, @@ -10,6 +11,7 @@ import { sessionState, setSessionAutoSend, setSessionDraftPrompt, + setNewSessionWorkspace, setSessionPendingWorkspace, type Session, type SessionTarget @@ -23,7 +25,14 @@ import type { OpenInSessionSource } from './OpenInSessionButton.svelte' import type { AppDomSelectorElement } from '../copilot/chat/context' // `seedDomElement` rides only with `autoSend`: it is the chip of the prompt being sent. -type SeedOpts = { seedPrompt?: string; autoSend?: boolean; seedDomElement?: AppDomSelectorElement } +// `untilStarted` keeps the session out of storage until the user starts it, for an item that +// is kept on its own (a new app's draft) and so loses nothing when the session goes. +type SeedOpts = { + seedPrompt?: string + autoSend?: boolean + seedDomElement?: AppDomSelectorElement + untilStarted?: boolean +} // The session/navigation switch turns the global rail into either the workspace // navigation (navigation mode) or the sessions sidebar (session mode). Session @@ -222,7 +231,10 @@ async function sessionHrefFor( // straight onto what the caller wants (resetSessionPreviewTabs also writes // through a live runtime if one already exists for this id). const session = createSession() - if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId) + if (opts?.untilStarted) { + holdSessionUntilStarted(session.id) + if (workspaceId) setNewSessionWorkspace(session.id, workspaceId) + } else if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId) if (opts?.seedPrompt) { setSessionDraftPrompt(session.id, opts.seedPrompt) if (opts.autoSend) setSessionAutoSend(session.id, opts.seedDomElement) @@ -253,7 +265,8 @@ export async function openSourceInSession( const opts = { seedPrompt: overrides?.seedPrompt ?? source.seedPrompt, autoSend: overrides?.autoSend ?? source.autoSend, - seedDomElement: overrides?.seedDomElement + seedDomElement: overrides?.seedDomElement, + untilStarted: overrides?.untilStarted } if (source.target) { await openEditorInSession( diff --git a/frontend/src/lib/components/triggers/TriggersEditor.svelte b/frontend/src/lib/components/triggers/TriggersEditor.svelte index ae32345907..0c7538ca25 100644 --- a/frontend/src/lib/components/triggers/TriggersEditor.svelte +++ b/frontend/src/lib/components/triggers/TriggersEditor.svelte @@ -58,6 +58,8 @@ noCapture?: boolean isEditor?: boolean onDeployTrigger?: (trigger: Trigger) => void + /** A flow in chat mode: its webhook examples then name a conversation. */ + chatMode?: boolean } let { @@ -75,7 +77,8 @@ schema = undefined, noCapture = false, isEditor = true, - onDeployTrigger + onDeployTrigger, + chatMode = false }: Props = $props() let config: Record<string, any> = $state({}) @@ -450,6 +453,7 @@ {newItem} {schema} {isEditor} + {chatMode} onDelete={() => { deleteTrigger(triggersState.selectedTriggerIndex) }} diff --git a/frontend/src/lib/components/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/triggers/TriggersWrapper.svelte index 72e96c9150..1c824a1795 100644 --- a/frontend/src/lib/components/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/triggers/TriggersWrapper.svelte @@ -39,6 +39,8 @@ onDelete?: () => void onReset?: () => void onEmailDomain: (domain: string) => void + /** A flow in chat mode: its webhook examples then name a conversation. */ + chatMode?: boolean } let { @@ -53,6 +55,7 @@ newItem, schema, onEmailDomain, + chatMode = false, ...props }: Props = $props() @@ -79,6 +82,7 @@ {args} scopes={isFlow ? [`jobs:run:flows:${currentPath}`] : [`jobs:run:scripts:${currentPath}`]} {newItem} + chat={isFlow && chatMode} /> {:else if selectedTrigger.type === 'default_email'} <DefaultEmailPanel diff --git a/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte index ad7d0badb2..1c3f34aca6 100644 --- a/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte +++ b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte @@ -9,8 +9,10 @@ import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte' import { sendUserToast } from '$lib/toast' import { Plus, RefreshCw, Unplug } from 'lucide-svelte' - import { userStore } from '$lib/stores' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' import { connectNativeAccount, getServiceConfig } from './utils' interface Props { @@ -45,6 +47,7 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() const displayName = $derived(getServiceConfig(service)?.serviceDisplayName ?? service) const confirmationModal = createAsyncConfirmationModal() @@ -95,7 +98,7 @@ } async function reconnect(c: NativeTriggerConnection) { - if (c.owner && c.owner !== $userStore?.username) { + if (c.owner && c.owner !== operatingUser.current?.username) { const confirmed = await confirmationModal.ask({ title: `Reconnect ${c.path} with your account?`, confirmationText: 'Reconnect', @@ -130,13 +133,16 @@ // Members cannot write the workspace connection; the server refuses anyone else it does not own. function canDisconnect(c: NativeTriggerConnection): boolean { - if ($userStore?.is_admin) return true - return !c.workspace && !!c.owner && c.owner === $userStore?.username + if (operatingUser.current?.is_admin) return true + return !c.workspace && !!c.owner && c.owner === operatingUser.current?.username } // The server lets only the connection's owner or an admin replace it. function canReconnect(c: NativeTriggerConnection): boolean { - return !!$userStore?.is_admin || (!!c.owner && c.owner === $userStore?.username) + return ( + !!operatingUser.current?.is_admin || + (!!c.owner && c.owner === operatingUser.current?.username) + ) } const items = $derived.by(() => { diff --git a/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte b/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte index 1862935997..4ca6162558 100644 --- a/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte +++ b/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte @@ -11,7 +11,6 @@ import bash from 'svelte-highlight/languages/bash' import { Tabs, Tab, TabContent, Button } from '$lib/components/common' import { ArrowDownRight, ArrowUpRight, Copy } from 'lucide-svelte' - import { Highlight } from 'svelte-highlight' import { typescript } from 'svelte-highlight/languages' import ClipboardPanel from '../../details/ClipboardPanel.svelte' import { copyToClipboard, isObject, readFieldsRecursively } from '$lib/utils' @@ -23,6 +22,17 @@ import { generateRandomString } from '$lib/utils' import TextInput from '$lib/components/text_input/TextInput.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import CollapseLink from '$lib/components/CollapseLink.svelte' + import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte' + import { markdown } from 'svelte-highlight/languages' + import { + curlExample, + fetchExample, + integrationPrompt, + sdkExample, + sdkReactExample, + type WebhookTarget + } from './webhookExamples' interface Props { isFlow?: boolean @@ -32,6 +42,11 @@ runnableArgs: any triggerTokens?: TriggerTokens | undefined scopes?: string[] + /** An AI agent rather than a script or a flow: it is run at its own endpoint, asynchronously. */ + agent?: boolean + /** It keeps the conversation (a chat-mode flow, an agent with managed memory): the examples + * then name one with `memory_id`, and the chat SDK is offered. */ + chat?: boolean } let { @@ -41,8 +56,11 @@ token = $bindable(''), runnableArgs, triggerTokens = $bindable(undefined), - scopes = [] + scopes = [], + agent = false, + chat = false }: Props = $props() + const kind: WebhookTarget['kind'] = $derived(agent ? 'agent' : isFlow ? 'flow' : 'script') const operatingWorkspace = useOperatingWorkspace() const wsId = $derived($operatingWorkspace) @@ -51,7 +69,9 @@ let baseWebhookUrl = $derived.by(() => { let webhookUrlPath: string - if (isFlow) { + if (agent) { + return `${WEBHOOK_BASE_URL}/run/agent/${path}` + } else if (isFlow) { webhookUrlPath = runnableId == 'path' ? `f/${path}` : `fv/${runnableVersion}` } else { webhookUrlPath = runnableId == 'path' ? `p/${path}` : `h/${runnableVersion}` @@ -66,13 +86,19 @@ } }) - let selectedTab: string = $state('rest') + let surface = $state('http') as 'http' | 'sdk' + let example = $state('rest') as 'rest' | 'curl' | 'fetch' + let promptOpen = $state(false) let userSettings: UserSettings | undefined = $state() let requestType = $state(DEFAULT_WEBHOOK_TYPE) as 'async' | 'sync' | 'sync_sse' let callMethod = $state('post') as 'get' | 'post' let runnableId = $state('path') as 'runnableVersion' | 'path' let tokenType = $state('headers') as 'query' | 'headers' + const sdkOffered = $derived(chat && kind !== 'script') + $effect(() => { + if (surface === 'sdk' && !sdkOffered) surface = 'http' + }) $effect(() => { if (requestType === 'async' && callMethod === 'get') { callMethod = 'post' @@ -81,24 +107,33 @@ let cleanedRunnableArgs = $derived.by(() => { readFieldsRecursively(runnableArgs) - return isObject(runnableArgs) && 'wm_trigger' in runnableArgs - ? Object.fromEntries(Object.entries(runnableArgs).filter(([key]) => key !== 'wm_trigger')) - : runnableArgs + const args = + isObject(runnableArgs) && 'wm_trigger' in runnableArgs + ? Object.fromEntries(Object.entries(runnableArgs).filter(([key]) => key !== 'wm_trigger')) + : runnableArgs + // A chat page fills no form, and each turn is a message. + return chat ? { ...args, user_message: args?.user_message || 'Hello' } : args }) - let url: string = $derived( - baseWebhookUrl + - (tokenType === 'query' - ? `?token=${token}${ - callMethod === 'get' || requestType === 'sync_sse' - ? `&payload=${encodeURIComponent(btoa(JSON.stringify(cleanedRunnableArgs ?? {})))}` - : '' - }` - : `${ - callMethod === 'get' - ? `?payload=${encodeURIComponent(btoa(JSON.stringify(cleanedRunnableArgs ?? {})))}` - : '' - }`) - ) + /** The url, naming the conversation as `memory` writes it in each example. */ + function urlWith(memory: string): string { + const params: string[] = [] + if (tokenType === 'query') params.push(`token=${token}`) + if (chat) params.push(`memory_id=${memory}`) + if (callMethod === 'get' || (tokenType === 'query' && requestType === 'sync_sse')) { + params.push(`payload=${encodeURIComponent(btoa(JSON.stringify(cleanedRunnableArgs ?? {})))}`) + } + return baseWebhookUrl + (params.length ? `?${params.join('&')}` : '') + } + let url: string = $derived(urlWith('<conversation id>')) + let target: WebhookTarget = $derived({ + api: `${apiBaseUrl('webhooks')}/api/w/${wsId}`, + kind, + path, + chat, + token, + inputs: cleanedRunnableArgs + }) + let sdkFlavor = $state('react') function headers() { const headers = {} @@ -113,11 +148,20 @@ } function fetchCode() { + if (agent) return fetchExample(target) + const fetchUrl = urlWith('${conversation}') + const conversation = chat + ? '// Any string names a conversation: reuse it for follow-ups, pick a new one to start over.\nconst conversation = crypto.randomUUID();\n\n' + : '' + return conversation + fetchBody(fetchUrl) + } + + function fetchBody(fetchUrl: string) { if (requestType === 'sync_sse') { return `import { EventSource } from "eventsource"; export async function main() { - const response = await fetch(\`${url}\`, { + const response = await fetch(\`${fetchUrl}\`, { method: '${callMethod === 'get' ? 'GET' : 'POST'}', headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n ')}, body: ${callMethod === 'get' ? 'undefined' : `JSON.stringify(${JSON.stringify(cleanedRunnableArgs ?? {}, null, 2).replaceAll('\n', '\n ')})`} @@ -184,7 +228,7 @@ async function triggerJob() { 2 ).replaceAll('\n', '\n\t')});` } - const endpoint = \`${url}\`; + const endpoint = \`${fetchUrl}\`; return await fetch(endpoint, { method: '${callMethod === 'get' ? 'GET' : 'POST'}', @@ -209,7 +253,7 @@ async function triggerJob() { '\n', '\n\t' )}); - const endpoint = \`${url}\`; + const endpoint = \`${fetchUrl}\`; return await fetch(endpoint, { method: '${callMethod === 'get' ? 'GET' : 'POST'}', @@ -252,9 +296,13 @@ function waitForJobCompletion(UUID) { } function curlCode() { - return `TOKEN='${token}' + if (agent) return curlExample(target) + const conversation = chat + ? '# Any string names a conversation: reuse it for follow-ups, pick a new one to start over.\nCONVERSATION=$(uuidgen)\n' + : '' + return `${conversation}TOKEN='${token}' ${callMethod !== 'get' ? `BODY='${JSON.stringify(cleanedRunnableArgs ?? {})}'` : ''} -URL='${url}' +${chat ? `URL="${urlWith('$CONVERSATION')}"` : `URL='${url}'`} ${requestType === 'sync' ? 'RESULT=$(' : requestType === 'async' ? 'UUID=$(' : ''}curl -s ${ callMethod != 'get' ? "-H 'Content-Type: application/json'" : '' } ${tokenType === 'headers' ? `-H "Authorization: Bearer $TOKEN"` : ''} -X ${ @@ -305,7 +353,7 @@ done` <Button size="xs" variant="default" on:click={() => userSettings?.openDrawer()}> Create a Webhook-specific Token <Tooltip light> - The token will have a scope such that it can only be used to trigger this script. It is + The token will have a scope such that it can only be used to trigger this {kind}. It is safe to share as it cannot be used to impersonate you. </Tooltip> </Button> @@ -313,154 +361,185 @@ done` </Label> {/if} - <div class="flex flex-col gap-6"> - <Label label="Request type"> - <ToggleButtonGroup bind:selected={requestType}> - {#snippet children({ item })} - <ToggleButton - label="Async" - value="async" - tooltip="The returning value is the uuid of the job assigned to execute the job." - {item} - /> - <ToggleButton - label="Sync" - value="sync" - tooltip="Triggers the execution, wait for the job to complete and return it as a response." - {item} - /> - <ToggleButton - label="Sync SSE" - value="sync_sse" - tooltip={'Triggers the execution and returns an SSE stream. ' + - (isFlow - ? 'Only useful if the last step of the flow returns a stream.' - : 'Only useful if the script returns a stream.')} - {item} - /> - {/snippet} - </ToggleButtonGroup> - </Label> - <Label label="Call method"> - <ToggleButtonGroup bind:selected={callMethod}> - {#snippet children({ item })} - <ToggleButton - label="POST" - icon={ArrowDownRight} - selectedColor="#14b8a6" - value="post" - {item} - /> - <ToggleButton - label="GET" - icon={ArrowUpRight} - selectedColor="#fb923c" - value="get" - {item} - disabled={requestType !== 'sync' && requestType !== 'sync_sse'} - /> - {/snippet} - </ToggleButtonGroup> - </Label> - <Label label="Reference type"> - <ToggleButtonGroup bind:selected={runnableId}> - {#snippet children({ item })} - <ToggleButton label="Path" value="path" {item} /> - <ToggleButton - label={isFlow ? 'Flow version' : 'Hash'} - value="runnableVersion" - disabled={!runnableVersion} - {item} - /> - {/snippet} - </ToggleButtonGroup> - </Label> - <Label label="Token configuration"> - <ToggleButtonGroup bind:selected={tokenType}> - {#snippet children({ item })} - <ToggleButton label="Token in Headers" value="headers" {item} /> - <ToggleButton label="Token in Query" value="query" {item} /> - {/snippet} - </ToggleButtonGroup> - </Label> - </div> - - <!-- svelte-ignore a11y_click_events_have_key_events --> - <!-- svelte-ignore a11y_no_static_element_interactions --> - <div> - <Tabs bind:selected={selectedTab}> - <Tab value="rest" label="REST" /> - {#if SCRIPT_VIEW_SHOW_EXAMPLE_CURL} - <Tab value="curl" label="Curl" /> + <Label label="AI prompt"> + <div class="flex flex-col gap-2"> + <div class="flex items-center gap-4"> + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Copy }} + onClick={() => copyToClipboard(integrationPrompt(target))} + > + Copy prompt for AI + </Button> + <CollapseLink text="Show prompt" class="mb-0" bind:open={promptOpen} /> + </div> + <span class="text-2xs text-hint"> + Share it with your coding assistant, after setting the token above as <code + >WINDMILL_TOKEN</code + > in your project's environment. + </span> + {#if promptOpen} + <CopyableCodeBlock code={integrationPrompt(target)} language={markdown} wrap /> {/if} - <Tab value="fetch" label="Fetch" /> - - {#snippet content()} - {#key token} - <TabContent value="rest" class="flex flex-col flex-1 h-full mt-2"> - <div class="flex flex-col gap-6"> - <Label label="Url"> - <ClipboardPanel content={url} /> - </Label> - - {#if callMethod !== 'get'} - <Label label="Body"> - <ClipboardPanel content={JSON.stringify(cleanedRunnableArgs ?? {}, null, 2)} /> - </Label> + </div> + </Label> + {#snippet httpExamples()} + <div class="flex flex-col gap-6"> + <!-- An agent has one endpoint: it starts a run, by path, and answers with its job id. --> + {#if !agent} + <Label label="Request type"> + <ToggleButtonGroup bind:selected={requestType}> + {#snippet children({ item })} + <ToggleButton + label="Async" + value="async" + tooltip="The returning value is the uuid of the job assigned to execute the job." + {item} + /> + <ToggleButton + label="Sync" + value="sync" + tooltip="Triggers the execution, wait for the job to complete and return it as a response." + {item} + /> + <ToggleButton + label="Sync SSE" + value="sync_sse" + tooltip={'Triggers the execution and returns an SSE stream. ' + + (isFlow + ? 'Only useful if the last step of the flow returns a stream.' + : 'Only useful if the script returns a stream.')} + {item} + /> + {/snippet} + </ToggleButtonGroup> + </Label> + <Label label="Call method"> + <ToggleButtonGroup bind:selected={callMethod}> + {#snippet children({ item })} + <ToggleButton + label="POST" + icon={ArrowDownRight} + selectedColor="#14b8a6" + value="post" + {item} + /> + <ToggleButton + label="GET" + icon={ArrowUpRight} + selectedColor="#fb923c" + value="get" + {item} + disabled={requestType !== 'sync' && requestType !== 'sync_sse'} + /> + {/snippet} + </ToggleButtonGroup> + </Label> + <Label label="Reference type"> + <ToggleButtonGroup bind:selected={runnableId}> + {#snippet children({ item })} + <ToggleButton label="Path" value="path" {item} /> + <ToggleButton + label={isFlow ? 'Flow version' : 'Hash'} + value="runnableVersion" + disabled={!runnableVersion} + {item} + /> + {/snippet} + </ToggleButtonGroup> + </Label> + <Label label="Token configuration"> + <ToggleButtonGroup bind:selected={tokenType}> + {#snippet children({ item })} + <ToggleButton label="Token in Headers" value="headers" {item} /> + <ToggleButton label="Token in Query" value="query" {item} /> + {/snippet} + </ToggleButtonGroup> + </Label> + {/if} + <div class="rounded-md border border-light bg-surface-tertiary"> + <div class="flex items-center justify-between gap-2 px-3 pt-3"> + <span class="text-sm font-semibold text-emphasis">Request</span> + <ToggleButtonGroup bind:selected={example} noWFull> + {#snippet children({ item })} + <ToggleButton label="REST" value="rest" {item} small /> + {#if SCRIPT_VIEW_SHOW_EXAMPLE_CURL} + <ToggleButton label="Curl" value="curl" {item} small /> {/if} - {#key callMethod} - {#key tokenType} - <Label label="Headers"> - <ClipboardPanel content={JSON.stringify(headers(), null, 2)} /> + <ToggleButton label="Fetch" value="fetch" {item} small /> + {/snippet} + </ToggleButtonGroup> + </div> + <div class="p-3"> + {#key token} + {#if example === 'rest'} + <div class="flex flex-col gap-6"> + <Label label="Url"> + <ClipboardPanel content={url} /> + </Label> + + {#if callMethod !== 'get'} + <Label label="Body"> + <ClipboardPanel content={JSON.stringify(cleanedRunnableArgs ?? {}, null, 2)} /> </Label> - {/key} - {/key} - </div> - </TabContent> - <TabContent value="curl" class="flex flex-col flex-1 h-full mt-2"> - <div class="relative"> - {#key runnableArgs} + {/if} {#key callMethod} - {#key requestType} - {#key tokenType} - <div - class="flex flex-row flex-1 h-full border p-2 rounded-md overflow-auto relative" - onclick={(e) => { - e.preventDefault() - copyToClipboard(curlCode()) - }} - > - <Highlight language={bash} code={curlCode()} /> - <Copy size={14} class="w-8 top-2 right-2 absolute cursor-pointer" /> - </div> - {/key} + {#key tokenType} + <Label label="Headers"> + <ClipboardPanel content={JSON.stringify(headers(), null, 2)} /> + </Label> {/key} {/key} - {/key} + </div> + {:else if example === 'curl'} + <CopyableCodeBlock code={curlCode()} language={bash} /> + {:else} + <CopyableCodeBlock code={fetchCode()} language={typescript} /> + {/if} + {/key} + </div> + </div> + </div> + {/snippet} + + <!-- The chat SDK names every endpoint itself, so the HTTP settings live under their own tab. --> + {#if sdkOffered} + <div> + <Tabs bind:selected={surface}> + <Tab value="http" label="HTTP" /> + <Tab value="sdk" label="Chat SDK" /> + {#snippet content()} + <TabContent value="http" class="mt-4"> + {@render httpExamples()} + </TabContent> + <TabContent value="sdk" class="flex flex-col gap-2 mt-4"> + <span class="text-xs text-secondary"> + Streams each answer and keeps the conversations, with any framework. + </span> + <div class="flex flex-col gap-3 rounded-md border border-light bg-surface-tertiary p-3"> + <div class="flex items-center justify-between gap-2"> + <span class="text-sm font-semibold text-emphasis">Usage</span> + <ToggleButtonGroup bind:selected={sdkFlavor} noWFull> + {#snippet children({ item })} + <ToggleButton value="react" label="React" {item} small /> + <ToggleButton value="any" label="Any framework" {item} small /> + {/snippet} + </ToggleButtonGroup> + </div> + <CopyableCodeBlock + code={sdkFlavor === 'react' ? sdkReactExample(target) : sdkExample(target)} + language={typescript} + /> </div> </TabContent> - <TabContent value="fetch" class="mt-2"> - {#key runnableArgs} - {#key callMethod} - {#key requestType} - {#key tokenType} - {#key token} - <div - class="flex flex-row flex-1 h-full border p-2 rounded-md overflow-auto relative" - onclick={(e) => { - e.preventDefault() - copyToClipboard(fetchCode()) - }} - > - <Highlight language={typescript} code={fetchCode()} /> - <Copy size={14} class="w-8 top-2 right-2 absolute cursor-pointer" /> - </div> - {/key}{/key}{/key}{/key} - {/key} - </TabContent> - {/key} - {/snippet} - </Tabs> - </div> - <TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="webhook" /> + {/snippet} + </Tabs> + </div> + {:else} + {@render httpExamples()} + {/if} + {#if !agent} + <TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="webhook" /> + {/if} </div> diff --git a/frontend/src/lib/components/triggers/webhook/WebhooksPanel.svelte b/frontend/src/lib/components/triggers/webhook/WebhooksPanel.svelte index f49c1f77f9..f17e1bd037 100644 --- a/frontend/src/lib/components/triggers/webhook/WebhooksPanel.svelte +++ b/frontend/src/lib/components/triggers/webhook/WebhooksPanel.svelte @@ -13,6 +13,9 @@ runnableVersion?: string | undefined path: string newItem?: boolean + /** See WebhooksConfigSection. */ + agent?: boolean + chat?: boolean } let { @@ -22,16 +25,25 @@ isFlow = false, runnableVersion = undefined, path, - newItem = false + newItem = false, + agent = false, + chat = false }: Props = $props() </script> <HighlightTheme /> -<Section label="Webhooks" class="flex flex-col gap-4"> +<Section label={agent ? 'API' : 'Webhooks'} class="flex flex-col gap-4"> <Description link="https://www.windmill.dev/docs/core_concepts/webhooks"> - Webhooks trigger scripts or flows via HTTP requests. Each webhook can be configured to run - synchronously or asynchronously. You can secure webhooks using tokens with specific permissions. + {#if agent} + Call the agent from your own code or app. Each request starts a run{chat + ? ', and a memory_id keeps the conversation going' + : ''}. Secure it with a token that can only run this agent. + {:else} + Webhooks trigger scripts or flows via HTTP requests. Each webhook can be configured to run + synchronously or asynchronously. You can secure webhooks using tokens with specific + permissions. + {/if} </Description> {#if newItem} <Alert type="warning" title="Attached to a deployed path"> @@ -39,5 +51,14 @@ {isFlow ? 'flow' : 'script'}. </Alert> {/if} - <WebhooksConfigSection {isFlow} {path} {runnableVersion} {token} runnableArgs={args} {scopes} /> + <WebhooksConfigSection + {isFlow} + {path} + {runnableVersion} + {token} + runnableArgs={args} + {scopes} + {agent} + {chat} + /> </Section> diff --git a/frontend/src/lib/components/flows/content/agentApiExamples.ts b/frontend/src/lib/components/triggers/webhook/webhookExamples.ts similarity index 57% rename from frontend/src/lib/components/flows/content/agentApiExamples.ts rename to frontend/src/lib/components/triggers/webhook/webhookExamples.ts index 52cdae6ddf..01ead33cfc 100644 --- a/frontend/src/lib/components/flows/content/agentApiExamples.ts +++ b/frontend/src/lib/components/triggers/webhook/webhookExamples.ts @@ -1,21 +1,45 @@ /** - * The code an agent's API tab offers to copy. Every example is meant to run as copied once the - * token is filled in, so each one is exercised against a live agent before it changes here. + * The code a webhook panel offers to copy beyond its REST, Curl and Fetch tabs: the chat SDK for + * what keeps a conversation, a brief for a coding assistant, and an agent's own Curl and Fetch. + * Every example is meant to run as copied once the token is filled in, so each one is exercised + * against a live script, flow or agent before it changes here. */ -export type AgentApiTarget = { +export type WebhookTarget = { /** `https://host/api/w/<workspace>`, base path included. */ api: string - agentPath: string - /** Whether the agent keeps the conversation: only then is `memory_id` worth sending. */ + kind: 'script' | 'flow' | 'agent' + path: string + /** Whether it keeps the conversation: an agent with managed memory, a flow in chat mode. Only + * then is `memory_id` worth sending. */ chat: boolean token: string + /** An example of its inputs, for a script or a flow outside chat mode. */ + inputs?: Record<string, unknown> } -export function runUrl({ api, agentPath }: AgentApiTarget): string { - return `${api}/jobs/run/agent/${agentPath}` +const noun = (t: WebhookTarget) => t.kind + +/** The segment of `/jobs/run/...` that names the target by path. */ +function runSegment({ kind, path }: WebhookTarget): string { + return kind === 'agent' ? `agent/${path}` : kind === 'flow' ? `f/${path}` : `p/${path}` } -export function curlExample(t: AgentApiTarget): string { +export function runUrl(t: WebhookTarget): string { + return `${t.api}/jobs/run/${runSegment(t)}` +} + +/** The token scope that can run this target and nothing else. */ +export function runScope({ kind, path }: WebhookTarget): string { + return `jobs:run:${kind === 'agent' ? 'agents' : kind === 'flow' ? 'flows' : 'scripts'}:${path}` +} + +/** What the conversations are filed under: an agent's under `<path>.chat`, a flow's under its path. */ +function conversationsPath({ kind, path }: WebhookTarget): string { + return kind === 'agent' ? `${path}.chat` : path +} + +/** Agents only: Curl that runs the agent and polls for its answer. */ +export function curlExample(t: WebhookTarget): string { const conversation = t.chat ? `# Any string names a conversation: reuse it for follow-ups, pick a new one to start over. CONVERSATION=$(uuidgen) @@ -36,7 +60,8 @@ while true; do done` } -export function fetchExample(t: AgentApiTarget): string { +/** Agents only: Fetch that runs the agent and streams its answer. */ +export function fetchExample(t: WebhookTarget): string { const usage = t.chat ? `export async function main() { // Any string names a conversation: reuse it for follow-ups, pick a new one to start over. @@ -54,7 +79,7 @@ ${usage} /** Runs the agent and streams its answer to \`onText\` as it is written; resolves to the full answer. */ async function ask(message${t.chat ? ', conversation' : ''}, onText) { - const run = await fetch(\`\${API}/jobs/run/agent/${t.agentPath}${t.chat ? '?memory_id=${conversation}' : ''}\`, { + const run = await fetch(\`\${API}/jobs/run/agent/${t.path}${t.chat ? '?memory_id=${conversation}' : ''}\`, { method: 'POST', headers: { ...HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ user_message: message }) @@ -102,17 +127,26 @@ async function ask(message${t.chat ? ', conversation' : ''}, onText) { }` } -function sdkOptions(t: AgentApiTarget, indent: string): string { - return `{ - baseUrl: '${t.api.replace(/\/api\/w\/[^/]+$/, '')}', +function sdkOptions(t: WebhookTarget, indent: string): string { + const connection = ` baseUrl: '${t.api.replace(/\/api\/w\/[^/]+$/, '')}', workspace: '${t.api.split('/').pop()}', - token: TOKEN, + token: TOKEN,` + if (t.kind !== 'agent') { + return `{ +${connection} + // With a token, history stays in this browser; history: 'server' also needs the token's + // flow_conversations:write scope. + flowPath: '${t.path}' +}`.replaceAll('\n', '\n' + indent) + } + return `{ +${connection} // What the agent's conversations are filed under. With a token, history stays in this // browser; history: 'server' also needs the token's flow_conversations:write scope. - flowPath: '${t.agentPath}.chat', + flowPath: '${t.path}.chat', // Runs the agent for each turn; the conversation id is its memory. run: async (args, { conversationId, signal }) => { - const res = await fetch(\`\${API}/jobs/run/agent/${t.agentPath}?memory_id=\${conversationId}\`, { + const res = await fetch(\`\${API}/jobs/run/agent/${t.path}?memory_id=\${conversationId}\`, { method: 'POST', headers: { Authorization: \`Bearer \${TOKEN}\`, 'Content-Type': 'application/json' }, body: JSON.stringify(args), @@ -124,16 +158,21 @@ function sdkOptions(t: AgentApiTarget, indent: string): string { }`.replaceAll('\n', '\n' + indent) } -export function sdkReactExample(t: AgentApiTarget): string { +/** The constants the SDK examples open with: an agent's `run` also needs the API's url. */ +function sdkConstants(t: WebhookTarget): string { + return `${t.kind === 'agent' ? `const API = '${t.api}'\n` : ''}const TOKEN = '${t.token}'` +} + +/** A chat over an agent or a chat-mode flow, in React. */ +export function sdkReactExample(t: WebhookTarget): string { return `import { useState } from 'react' // On the Vercel AI SDK or assistant-ui? Use @windmill-labs/chat-sdk/ai-sdk or /assistant-ui instead: // https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme import { useWindmillChat } from '@windmill-labs/chat-sdk/react' -const API = '${t.api}' -const TOKEN = '${t.token}' +${sdkConstants(t)} -export function AgentChat() { +export function ${t.kind === 'agent' ? 'AgentChat' : 'FlowChat'}() { const chat = useWindmillChat(${sdkOptions(t, ' ')}) const [draft, setDraft] = useState('') // Also chat.stop(), and the conversations: chat.conversations, chat.selectConversation(id), chat.newConversation() @@ -148,11 +187,11 @@ export function AgentChat() { }` } -export function sdkExample(t: AgentApiTarget): string { +/** A chat over an agent or a chat-mode flow, with any framework or none. */ +export function sdkExample(t: WebhookTarget): string { return `import { createChat } from '@windmill-labs/chat-sdk' -const API = '${t.api}' -const TOKEN = '${t.token}' +${sdkConstants(t)} const chat = createChat(${sdkOptions(t, '')}) @@ -163,20 +202,25 @@ await chat.sendMessage('Hello') // Also chat.stop(), and the conversations: chat.newConversation(), chat.selectConversation(id)` } -/** A brief for a coding assistant asked to integrate the agent. It reads the token from `WINDMILL_TOKEN`. */ -export function integrationPrompt(t: AgentApiTarget): string { - const conversation = t.chat - ? ` +function conversationSection(t: WebhookTarget): string { + const sdkPointer = + t.kind === 'agent' + ? `Point it at the +agent with \`flowPath: '${t.path}.chat'\` and a \`run(args, { conversationId, signal })\` option +that POSTs \`args\` to the run endpoint with \`memory_id=conversationId\` and returns the job id.` + : `Point it at the +flow with \`flowPath: '${t.path}'\`: it runs the flow itself.` + return ` ## Conversations -The agent keeps the conversation. Pass \`memory_id\` as a query parameter on every run: any +The ${noun(t)} keeps the conversation. Pass \`memory_id\` as a query parameter on every run: any string names a conversation, a UUID is best. Reuse it for follow-ups; a new one starts over. -Without it each message is answered on its own. +${t.kind === 'agent' ? 'Without it each message is answered on its own.' : 'It is required: a run without it is refused, so answer a message on its own with a fresh one.'} Windmill also keeps each conversation, as the token's owner's. One token shared by all your users makes every conversation theirs, so keep your users' conversation ids in your own app. Reading them back needs a token with the \`flow_conversations:read\` scope (\`:write\` to delete): -- \`GET ${t.api}/flow_conversations/list?flow_path=${t.agentPath}.chat\` (\`page\`, \`per_page\`). +- \`GET ${t.api}/flow_conversations/list?flow_path=${conversationsPath(t)}\` (\`page\`, \`per_page\`). A conversation's \`id\` is the \`memory_id\` when that was a UUID. - \`GET ${t.api}/flow_conversations/{id}/messages\`: its latest messages, oldest first. - \`DELETE ${t.api}/flow_conversations/delete/{id}\`. @@ -186,27 +230,31 @@ https://github.com/windmill-labs/windmill/tree/main/chat-sdk): it does all of th \`createChat\` from \`@windmill-labs/chat-sdk\` works with any framework (or none); use the adapter that matches the project when there is one: \`useWindmillChat\` from \`@windmill-labs/chat-sdk/react\`, the Vercel AI SDK transport (\`@windmill-labs/chat-sdk/ai-sdk\`) or the assistant-ui runtime -(\`@windmill-labs/chat-sdk/assistant-ui\`). Point it at the -agent with \`flowPath: '${t.agentPath}.chat'\` and a \`run(args, { conversationId, signal })\` option -that POSTs \`args\` to the run endpoint with \`memory_id=conversationId\` and returns the job id. +(\`@windmill-labs/chat-sdk/assistant-ui\`). ${sdkPointer} ` - : '' - return `Integrate the Windmill AI agent \`${t.agentPath}\` into this project. +} -## Authentication +function authSection(t: WebhookTarget): string { + const specific = `${t.kind === 'agent' ? 'an\nagent' : `a\n${t.kind}`}-specific token (scope \`${runScope(t)}\`): that one can only run this ${noun(t)}` + return `## Authentication Every request sends \`Authorization: Bearer <token>\`. Read the token from the \`WINDMILL_TOKEN\` -environment variable (or the project's secret store under that name), never hardcode it: the agent -runs as the token's owner, with their permissions. Keep it on the server, unless it is an -agent-specific token (scope \`jobs:run:agents:${t.agentPath}\`): that one can only run this agent, so a +environment variable (or the project's secret store under that name), never hardcode it: the ${noun(t)} +runs as the token's owner, with their permissions. Keep it on the server, unless it is ${specific}, so a browser app may read it from the bundler's public variable (\`VITE_WINDMILL_TOKEN\`, -\`NEXT_PUBLIC_WINDMILL_TOKEN\`, ...). +\`NEXT_PUBLIC_WINDMILL_TOKEN\`, ...).` +} + +function agentPrompt(t: WebhookTarget): string { + return `Integrate the Windmill AI agent \`${t.path}\` into this project. + +${authSection(t)} ## Sending a message \`POST ${runUrl(t)}\` with the JSON body \`{"user_message": "..."}\` starts a run and returns its job id as plain text. An error is a non-2xx status with the reason as text. -${conversation} +${t.chat ? conversationSection(t) : ''} ## Streaming the answer \`GET ${t.api}/jobs_u/getupdate_sse/{job_id}?fast=true&only_result=true\` is a server-sent events @@ -226,3 +274,35 @@ Without streaming, poll \`GET ${t.api}/jobs_u/completed/get_result_maybe/{job_id To stop a run: \`POST ${t.api}/jobs_u/queue/cancel/{job_id}\` with \`{"reason": "..."}\`. ` } + +function runnablePrompt(t: WebhookTarget): string { + const segment = runSegment(t) + const body = t.chat + ? '`{"user_message": "..."}`' + : `its inputs, for example \`${JSON.stringify(t.inputs ?? {})}\`` + return `Integrate the Windmill ${t.chat ? 'chat flow' : noun(t)} \`${t.path}\` into this project. + +${authSection(t)} + +## Running it + +The JSON body of each request is ${body}. An error is a non-2xx status with the reason as text. +- \`POST ${t.api}/jobs/run/${segment}\` starts a run and returns its job id as plain text. +- \`POST ${t.api}/jobs/run_wait_result/${segment}\` runs it and answers with its result as JSON, for + runs short enough to wait on. +- \`POST ${t.api}/jobs/run_and_stream/${segment}\` answers with a server-sent events stream of its + updates, for a ${noun(t)} that streams its result. +${t.chat ? conversationSection(t) : ''} +## Reading the result of a started run + +Poll \`GET ${t.api}/jobs_u/completed/get_result_maybe/{job_id}\` until \`completed\` is true. \`result\` +is what the ${noun(t)} returned${t.kind === 'flow' ? ' (a flow ending on an AI agent step answers in `result.output`)' : ''}, or carries \`result.error.message\` when the run failed. + +To stop a run: \`POST ${t.api}/jobs_u/queue/cancel/{job_id}\` with \`{"reason": "..."}\`. +` +} + +/** A brief for a coding assistant asked to call the target. It reads the token from `WINDMILL_TOKEN`. */ +export function integrationPrompt(t: WebhookTarget): string { + return t.kind === 'agent' ? agentPrompt(t) : runnablePrompt(t) +} diff --git a/frontend/src/lib/components/workspaceSettings/DbtSettings.svelte b/frontend/src/lib/components/workspaceSettings/DbtSettings.svelte index c56ce514c5..b6de5faa95 100644 --- a/frontend/src/lib/components/workspaceSettings/DbtSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DbtSettings.svelte @@ -21,6 +21,7 @@ 'snowflake', 'snowflake_oauth', 'bigquery', + 'google_service_account', 'gcp_service_account', 'databricks' ].join(',') diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index a81110805c..1c0a5d9798 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -1339,7 +1339,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "execution_mode": { "type": "string", - "description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. A guest is only admitted where the workspace also has `guest_access_enabled`, which is checked when the session is minted and again on every guest request. Possible values: viewer, publisher, guest, anonymous" + "description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. Widening an app to `guest` is gated by the `RestrictGuestAppDeployment` protection rule. Possible values: viewer, publisher, guest, anonymous" }, "on_behalf_of": { "type": "string", @@ -1456,7 +1456,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "execution_mode": { "type": "string", - "description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. A guest is only admitted where the workspace also has `guest_access_enabled`, which is checked when the session is minted and again on every guest request. Possible values: viewer, publisher, guest, anonymous" + "description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. Widening an app to `guest` is gated by the `RestrictGuestAppDeployment` protection rule. Possible values: viewer, publisher, guest, anonymous" }, "on_behalf_of": { "type": "string", diff --git a/frontend/src/lib/rawAppDeploy.ts b/frontend/src/lib/rawAppDeploy.ts index 2a9bea2973..d146f74bc1 100644 --- a/frontend/src/lib/rawAppDeploy.ts +++ b/frontend/src/lib/rawAppDeploy.ts @@ -10,10 +10,9 @@ * files, then createAppRaw/updateAppRaw. The source→AppDraftValue projection is * shared via `rawAppDraftValue` so the two deploy paths can't drift. */ -import { get } from 'svelte/store' import { AppService } from '$lib/gen' import type { Policy } from '$lib/gen' -import { userStore } from '$lib/stores' +import { isAdminIn } from '$lib/user' import { bundleRawAppDraft } from '$lib/components/copilot/chat/global/rawAppBundlerBridge' import type { AppDraftValue } from '$lib/components/copilot/chat/global/workspaceItems' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' @@ -63,7 +62,7 @@ export async function deployRawAppValue({ const bundle = await bundleRawAppDraft({ workspace, files }) - const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const isAdmin = await isAdminIn(workspace) await AppService.updateAppRaw({ workspace, path, @@ -131,7 +130,7 @@ export async function deployRawAppDraft( // non-admin deploying a draft for an app that has a custom route would hit // RequireAdmin (the deployed custom_path is sent via the appSourceToDraftValue // fallback even when unchanged). - const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const isAdmin = await isAdminIn(workspace) await AppService.updateAppRaw({ workspace, path, diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index 12d6f237ba..1aa4a10267 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -39,6 +39,12 @@ export function getWorkspaceRole(workspace: string): Promise<RoleLookup> { return lookup } +/** Admin of `workspace`, which may not be the navigated one; an unknown user is not. */ +export async function isAdminIn(workspace: string): Promise<boolean> { + const role = await getWorkspaceRole(workspace) + return role.kind === 'resolved' && !!(role.user.is_admin || role.user.is_super_admin) +} + /** Roles are per-identity, so anything that changes who is logged in must drop this. */ export function clearWorkspaceRoleCache(): void { workspaceRoleCache.clear() diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index c4f2b3749e..4ffbfc2508 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -12,7 +12,7 @@ * draft, so discarding deletes the whole item (mirrors `common/table/*Row.svelte`); * a draft on an already-deployed item just deletes the draft row. */ -import { get, writable } from 'svelte/store' +import { writable } from 'svelte/store' import { DraftService, ScriptService, @@ -43,7 +43,7 @@ import { classicAppDraftParts } from '$lib/appDiffSides' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import { invalidateWorkspaceComparison } from '$lib/workspaceComparison' import { setLocalDraftHint } from '$lib/localDraftHints.svelte' -import { userStore } from '$lib/stores' +import { isAdminIn } from '$lib/user' import { deployTriggers, type Trigger } from '$lib/components/triggers/utils' import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' import { dropStaleStepLocks } from '$lib/components/flows/staleStepLocks' @@ -449,7 +449,7 @@ async function deployDraftTriggers( ): Promise<void> { const triggers = (draftTriggers ?? []).filter((t) => t?.draftConfig) if (triggers.length === 0) return - const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const isAdmin = await isAdminIn(workspace) await deployTriggers(triggers, workspace, isAdmin, writable<string[]>([]), path, isNew) } @@ -586,7 +586,7 @@ export async function deployDraft( // (or a default). custom_path requires admin on update; non-admins send // undefined so the backend preserves the existing route. The draft has no // custom_path, so admins fall back to the deployed route (`''` when none). - const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const isAdmin = await isAdminIn(workspace) const policy = r.policy ?? { execution_mode: 'publisher', sandbox: true } const requestBody = { value: appValue, @@ -720,7 +720,7 @@ export async function deployDraft( } else if (kind in TRIGGER_SAVERS) { const getter = OVERLAY_GETTERS[kind]! const { draft: d } = splitOverlay(await getter(workspace, path)) - const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const isAdmin = await isAdminIn(workspace) const ok = await TRIGGER_SAVERS[kind]!( path, { ...d, path: d.path ?? path }, diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index aa51d62ea8..a90a5ae587 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -12,6 +12,9 @@ import type { HiddenRunnable } from '$lib/components/apps/types' import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte' import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate' + import { copilotInfo, copilotWorkspace } from '$lib/aiStore' + import { loadCopilot } from '$lib/components/copilot/loadCopilot' + import { get } from 'svelte/store' import { openEditorInSession } from '$lib/components/sessions/sessionSwitch.svelte' import { stateSnapshot } from '$lib/svelte5Utils.svelte' import { page } from '$app/state' @@ -278,6 +281,10 @@ newPath = pathParam ?? '' // Imported content is the starting state — skip the picker. templatePicker = false + if (importRaw.openInAiSession && files) { + const app = { files, runnables, data, summary, policy } + void tick().then(() => handOffToSession(app)) + } return } // Seed the React 19 template so the editor has a usable state even if the @@ -549,6 +556,45 @@ return composeOnlyApps ? {} : structuredClone(STARTER_RUNNABLES) } + /** Opens the app in an AI session when sessions are how this user gets AI; false when the + * editor should keep it instead. */ + async function handOffToSession(app: Omit<RawAppDraft, 'draft_path'>): Promise<boolean> { + const workspace = $workspaceStore + // Inside a session's preview the sessions page refuses to render, so the editor keeps it. + if (window.self !== window.top) return false + if (!prefersSessionHandoff(!$roleCaps.code) || !workspace) return false + if (get(copilotWorkspace) !== workspace) await loadCopilot(workspace) + if (get(copilotWorkspace) !== workspace || !get(copilotInfo).enabled) return false + // The session loads the app by path, so it is saved before it opens. `new_draft` tells + // it the app is still the starter template. + try { + draftSync.draft = { + ...app, + ...(pendingDraftPath ? { draft_path: pendingDraftPath } : {}) + } as RawAppDraft + // The autosave this write queues would otherwise land after the session has + // read the draft, leaving it a baseline older than the row. + await tick() + await UserDraftDbSyncer.flush({ workspace, itemKind: 'raw_app', path }) + await UserDraft.forcePersist('raw_app', path, { workspace }) + // A failed save is recorded, not thrown, and the session would open on nothing. + const saved = UserDraftDbSyncer.getState({ workspace, itemKind: 'raw_app', path }) + if (saved.state === 'failed') { + throw new Error(saved.failureMessage ?? 'the app could not be saved') + } + await openEditorInSession( + { kind: 'raw_app', path }, + workspace, + { new_draft: 'true' }, + { untilStarted: true } + ) + return true + } catch (e) { + sendUserToast(`Could not open the AI session: ${e?.body ?? e?.message ?? e}`, true) + return false + } + } + async function onTemplatePickerStart(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) { const picked = { files: { ...result.files }, @@ -557,31 +603,7 @@ summary: '', policy: result.policy } - const workspace = $workspaceStore - if (mode === 'ai' && prefersSessionHandoff(!$roleCaps.code) && workspace) { - // The session loads the app by path, so the picked setup is saved before it - // opens. `new_draft` tells it the app is still the starter template. - try { - draftSync.draft = { - ...picked, - ...(pendingDraftPath ? { draft_path: pendingDraftPath } : {}) - } as RawAppDraft - // The autosave this write queues would otherwise land after the session has - // read the draft, leaving it a baseline older than the row. - await tick() - await UserDraftDbSyncer.flush({ workspace, itemKind: 'raw_app', path }) - await UserDraft.forcePersist('raw_app', path, { workspace }) - // A failed save is recorded, not thrown, and the session would open on nothing. - const saved = UserDraftDbSyncer.getState({ workspace, itemKind: 'raw_app', path }) - if (saved.state === 'failed') { - throw new Error(saved.failureMessage ?? 'the app could not be saved') - } - await openEditorInSession({ kind: 'raw_app', path }, workspace, { new_draft: 'true' }) - return - } catch (e) { - sendUserToast(`Could not open the AI session: ${e?.body ?? e?.message ?? e}`, true) - } - } + if (mode === 'ai' && (await handOffToSession(picked))) return files = picked.files runnables = picked.runnables data = picked.data diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index f0b866b373..a34a97c45b 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -94,11 +94,7 @@ tabsToReload, viewerTabsToReload } from '$lib/components/sessions/previewReload' - import { - isPreviewListPath, - barePath, - agentPagePath - } from '$lib/components/sessions/previewPaths' + import { isPreviewListPath, barePath, agentPagePath } from '$lib/components/sessions/previewPaths' import { listWorkspaceAgents } from '$lib/components/sessions/workspaceAgents' import { leafKeyFor, @@ -268,6 +264,15 @@ .map((r) => sessionState.sessions.find((s) => s.id === r.sessionId)) .filter((s): s is NonNullable<typeof s> => s != null) ) + // The preview hosts take their runtime from the item rather than a lookup by id: a + // removed session's hosts are torn down after its runtime has left the map, and their + // teardowns (an editor unregistering from the runtime) still read the prop. + const warmHosts = $derived( + listRuntimes().flatMap((runtime) => { + const session = sessionState.sessions.find((s) => s.id === runtime.sessionId) + return session ? [{ session, runtime }] : [] + }) + ) // Chat columns stay mounted (stacked, visibility-toggled) so switching back // keeps scroll position, MRU-capped like preview tabs: every mounted column @@ -1272,9 +1277,8 @@ <div class="relative flex-1 min-h-0"> {#if previewTabHostComponent.current} {@const TabHost = previewTabHostComponent.current} - {#each warmSessions as s (s.id)} - {@const rt = getRuntime(s.id)} - {@const tabs = rt?.previewTabs} + {#each warmHosts as { session: s, runtime: rt } (s.id)} + {@const tabs = rt.previewTabs} {#each tabs?.tabs ?? [] as tab (tab.id)} <!-- tabHosts is an imperative ref-bag (only tabHosts[key]?.reload() in reloadTabs); it is intentionally a plain object so component diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 5ba85cdbe0..2f645f8953 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -241,9 +241,7 @@ let criticalAlertUIMuted: boolean | undefined = $state(undefined) let initialCriticalAlertUIMuted: boolean | undefined = $state(undefined) let publicAppRateLimitPerMinute: number | undefined = $state(undefined) - let guestAccessEnabled: boolean = $state(false) let guestUsage: GuestUsage | undefined = $state(undefined) - let initialGuestAccessEnabled: boolean = $state(false) // A guest JWT is verified against one key: a PEM public key, or a JWKS URL. The // type picks which field is live; the other is cleared on save. let guestJwtKeyType = $state<'pem' | 'jwks'>('pem') @@ -257,8 +255,8 @@ pem: guestJwtKeyType === 'pem' ? guestJwtPublicKey.trim() : '', jwks: guestJwtKeyType === 'jwks' ? guestJwtJwksUrl.trim() : '' }) - // Whether the deployment can have guests at all; off, the card offers no switch to - // turn on. The backend decides; the hostname stands in until it has answered. + // Whether the deployment can have guests at all; off, the card offers no guest key to + // configure. The backend decides; the hostname stands in until it has answered. let guestsAvailable = $derived.by(() => guestUsage?.available ?? !isCloudHosted()) let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined) @@ -697,11 +695,8 @@ } async function saveDefaultAppSettings(): Promise<void> { - // Guest access and the guest JWT key are the writes of this card available on every plan; - // save them first so a refused Enterprise-only write after cannot swallow them. - if (guestAccessEnabled !== initialGuestAccessEnabled) { - await editGuestAccess() - } + // The guest JWT key is the write of this card available on every plan; save it first so + // a refused Enterprise-only write after cannot swallow it. if ( effectiveGuestJwt.pem !== initialGuestJwtPublicKey || effectiveGuestJwt.jwks !== initialGuestJwtJwksUrl @@ -743,19 +738,6 @@ sendUserToast('Guest JWT key cleared') } - async function editGuestAccess(): Promise<void> { - await WorkspaceService.editGuestAccess({ - workspace: $workspaceStore!, - requestBody: { guest_access_enabled: guestAccessEnabled } - }) - initialGuestAccessEnabled = guestAccessEnabled - sendUserToast( - guestAccessEnabled - ? 'Guests can now open apps set to Guests in this workspace' - : 'Guests can no longer sign in to this workspace' - ) - } - async function loadWorkspaceEncryptionKey(): Promise<void> { let resp = await WorkspaceService.getWorkspaceEncryptionKey({ workspace: $workspaceStore! @@ -841,8 +823,6 @@ initialCriticalAlertUIMuted = settings.mute_critical_alerts publicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined initialPublicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined - guestAccessEnabled = settings.guest_access_enabled ?? false - initialGuestAccessEnabled = settings.guest_access_enabled ?? false guestJwtPublicKey = settings.guest_jwt_public_key ?? '' guestJwtJwksUrl = settings.guest_jwt_jwks_url ?? '' initialGuestJwtPublicKey = guestJwtPublicKey @@ -1351,14 +1331,12 @@ savedValue: { defaultAppPath: initialWorkspaceDefaultAppPath, publicAppRateLimitPerMinute: initialPublicAppRateLimitPerMinute, - guestAccessEnabled: initialGuestAccessEnabled, guestJwtPem: initialGuestJwtPublicKey, guestJwtJwks: initialGuestJwtJwksUrl }, modifiedValue: { defaultAppPath: workspaceDefaultAppPath, publicAppRateLimitPerMinute: publicAppRateLimitPerMinute, - guestAccessEnabled: guestAccessEnabled, guestJwtPem: effectiveGuestJwt.pem, guestJwtJwks: effectiveGuestJwt.jwks } @@ -1369,7 +1347,6 @@ function discardDefaultAppSettingsChanges() { workspaceDefaultAppPath = initialWorkspaceDefaultAppPath publicAppRateLimitPerMinute = initialPublicAppRateLimitPerMinute - guestAccessEnabled = initialGuestAccessEnabled guestJwtPublicKey = initialGuestJwtPublicKey guestJwtJwksUrl = initialGuestJwtJwksUrl guestJwtKeyType = initialGuestJwtJwksUrl ? 'jwks' : 'pem' @@ -2018,7 +1995,9 @@ onclick={saveSlackSigningSecret} disabled={!slackSigningSecret.trim() && !slackSigningSecretSet} > - {slackSigningSecret.trim() || !slackSigningSecretSet ? 'Save' : 'Remove'} + {slackSigningSecret.trim() || !slackSigningSecretSet + ? 'Save' + : 'Remove'} </Button> </div> <div class="text-2xs text-hint" @@ -2704,7 +2683,7 @@ export async function main( <SettingCard label="Guests" - description="Let anyone your identity provider authenticates, or a JWT your own backend signs (configured below), open the apps set to Guests without a Windmill account. They join no workspace, see nothing else, and take no seat. Off by default. Turning it off stops guests immediately, even for apps already set to Guests." + description="An app set to Guests can be opened by anyone your identity provider authenticates, or by a JWT your own backend signs (configured below), without a Windmill account. They join no workspace, see nothing else, and take no seat. To limit who may set an app to Guests, add the 'Restrict guest app access' rule to a protection ruleset." class="mt-6" > {#if !guestsAvailable} @@ -2723,16 +2702,7 @@ export async function main( </div> {/if} {:else} - <Toggle - bind:checked={guestAccessEnabled} - options={{ right: 'Allow guests to open apps set to Guests' }} - /> - {#if guestUsage && !guestUsage.instance_enabled} - <span class="text-hint text-2xs"> - A superadmin has turned guests off for this instance, so this switch has no - effect until they are allowed again. - </span> - {:else if guestUsage} + {#if guestUsage} <span class="text-hint text-2xs"> {guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this instance in the last {guestUsage.window_days} days. @@ -2802,7 +2772,6 @@ export async function main( onDiscard={discardDefaultAppSettingsChanges} saveLabel="Save app settings" disabled={!$enterpriseLicense && - guestAccessEnabled === initialGuestAccessEnabled && effectiveGuestJwt.pem === initialGuestJwtPublicKey && effectiveGuestJwt.jwks === initialGuestJwtJwksUrl} /> diff --git a/lsp/Pipfile b/lsp/Pipfile index d7f2c0b69f..6b441070ac 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.851.0" +wmill = ">=1.853.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index e9dcb30e78..35bf4b9d65 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.851.0 + version: 1.853.0 title: OpenFlow Spec contact: name: Ruben Fiszel @@ -308,7 +308,7 @@ components: required: - expr sleep: - description: Delay before executing this step (in seconds or as expression) + description: Delay after this step completes, before the next step starts (in seconds or as expression) $ref: '#/components/schemas/InputTransform' cache_ttl: type: number diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 8b56bef5f9..3dc5057af7 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.851.0' + ModuleVersion = '1.853.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index c905d548d2..b1d7f810e1 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.851.0" +version = "1.853.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index a40abaf411..892c0fabc5 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -177,7 +177,7 @@ flow related commands - `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting. - `--tag <tag:string>` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). -- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). +- `flow preview <flow_path:string>` - preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). - `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - `-s --silent` - Do not output anything other then the final output. Useful for scripting. - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index f7be020352..6a3cbafe50 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -619,4 +619,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"requires_approval":{"type":"boolean","description":"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"},{"$ref":"#/components/schemas/QuestionToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue","question":"#/components/schemas/QuestionToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"QuestionToolValue":{"type":"object","description":"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.","properties":{"tool_type":{"type":"string","enum":["question"]},"timeout":{"type":"integer","description":"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800."}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["approval"]},"call_id":{"type":"string"},"calls":{"type":"array","items":{"type":"object","properties":{"function_name":{"type":"string"},"arguments":{}},"required":["function_name","arguments"]}},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","calls","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["question"]},"call_id":{"type":"string"},"question":{"type":"string"},"choices":{"type":"array","items":{"type":"string"},"description":"answers the model proposes; a free-text answer is always possible"},"multi_select":{"type":"boolean","description":"whether several choices can be picked together"},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","question","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'"}},"required":["expr"]},"sleep":{"description":"Delay after this step completes, before the next step starts (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"requires_approval":{"type":"boolean","description":"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"},{"$ref":"#/components/schemas/QuestionToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue","question":"#/components/schemas/QuestionToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"QuestionToolValue":{"type":"object","description":"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.","properties":{"tool_type":{"type":"string","enum":["question"]},"timeout":{"type":"integer","description":"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800."}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["approval"]},"call_id":{"type":"string"},"calls":{"type":"array","items":{"type":"object","properties":{"function_name":{"type":"string"},"arguments":{}},"required":["function_name","arguments"]}},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","calls","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["question"]},"call_id":{"type":"string"},"question":{"type":"string"},"choices":{"type":"array","items":{"type":"string"},"description":"answers the model proposes; a free-text answer is always possible"},"multi_select":{"type":"boolean","description":"whether several choices can be picked together"},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","question","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 9965d9ae63..3ee03a96f8 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1617,7 +1617,7 @@ write a secret you were not given: keep the placeholder. | \`slack_command_script\` / \`teams_command_script\` | Script or flow handling the Slack / Teams command. | | \`slack_oauth_client_id\` / \`slack_oauth_client_secret\` | The workspace's own Slack OAuth app. Clearing either removes both. | | \`public_app_execution_limit_per_minute\` | Rate limit for public apps, \`null\` for none. | -| \`guest_access_enabled\`, \`guest_jwt_public_key\`, \`guest_jwt_jwks_url\` | Guest access to public apps and the key that signs guest JWTs. | +| \`guest_jwt_public_key\`, \`guest_jwt_jwks_url\` | The key that signs guest JWTs for apps set to Guests. | | \`add_admins_and_developers_to_forks\` | Add the workspace's admins and developers to each fork. | | \`color\` | Workspace color, \`#rrggbb\`. | `; @@ -1818,6 +1818,7 @@ A caught failure reads the same whether it came from a task or from a \`step()\` export const FLOW_CHAT_SPECIAL_MODULES = `## Special Modules - Use \`set_preprocessor_module\` to add, replace, or remove the top-level \`value.preprocessor_module\` +- A preprocessor receives the trigger's \`event\`, not the flow's inputs: leave its \`input_transforms\` empty - Use \`set_failure_module\` to add, replace, or remove the top-level \`value.failure_module\` - Use \`set_flow_json\` only when you are replacing the whole flow, including normal modules and optional special modules @@ -1829,10 +1830,8 @@ set_preprocessor_module({ value: { type: "rawscript", language: "bun", - content: "export async function preprocessor(payload: string) { const trimmed = payload.trim(); if (!trimmed) { throw new Error('payload must not be empty'); } return { payload: trimmed }; }", - input_transforms: { - payload: { type: "javascript", expr: "flow_input.payload" } - } + content: "export async function preprocessor(event: { kind: string; body?: { payload?: string } }) { const payload = String(event.body?.payload ?? '').trim(); if (!payload) { throw new Error('payload must not be empty'); } return { payload }; }", + input_transforms: {} } }) }) @@ -3867,7 +3866,7 @@ class SqlQuery: export const OPENFLOW_SCHEMA = `## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"requires_approval":{"type":"boolean","description":"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"},{"$ref":"#/components/schemas/QuestionToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue","question":"#/components/schemas/QuestionToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"QuestionToolValue":{"type":"object","description":"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.","properties":{"tool_type":{"type":"string","enum":["question"]},"timeout":{"type":"integer","description":"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800."}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n\`flow_input.model\` to choose the model per run.\\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside \`provider\`.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\\nand, optionally, the \`model\` and \`reasoning_effort\` overrides of what the agent saved.\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default \`flow\`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["approval"]},"call_id":{"type":"string"},"calls":{"type":"array","items":{"type":"object","properties":{"function_name":{"type":"string"},"arguments":{}},"required":["function_name","arguments"]}},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","calls","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["question"]},"call_id":{"type":"string"},"question":{"type":"string"},"choices":{"type":"array","items":{"type":"string"},"description":"answers the model proposes; a free-text answer is always possible"},"multi_select":{"type":"boolean","description":"whether several choices can be picked together"},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","question","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'"}},"required":["expr"]},"sleep":{"description":"Delay after this step completes, before the next step starts (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"requires_approval":{"type":"boolean","description":"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"},{"$ref":"#/components/schemas/QuestionToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue","question":"#/components/schemas/QuestionToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"QuestionToolValue":{"type":"object","description":"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.","properties":{"tool_type":{"type":"string","enum":["question"]},"timeout":{"type":"integer","description":"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800."}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n\`flow_input.model\` to choose the model per run.\\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside \`provider\`.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\\nand, optionally, the \`model\` and \`reasoning_effort\` overrides of what the agent saved.\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default \`flow\`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["approval"]},"call_id":{"type":"string"},"calls":{"type":"array","items":{"type":"object","properties":{"function_name":{"type":"string"},"arguments":{}},"required":["function_name","arguments"]}},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","calls","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["question"]},"call_id":{"type":"string"},"question":{"type":"string"},"choices":{"type":"array","items":{"type":"string"},"description":"answers the model proposes; a free-text answer is always possible"},"multi_select":{"type":"boolean","description":"whether several choices can be picked together"},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","question","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands @@ -4048,7 +4047,7 @@ flow related commands - \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting. - \`--tag <tag:string>\` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). -- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). +- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). - \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 7770d98ea9..1dd1adf793 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -182,7 +182,7 @@ flow related commands - `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting. - `--tag <tag:string>` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). -- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). +- `flow preview <flow_path:string>` - preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). - `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - `-s --silent` - Do not output anything other then the final output. Useful for scripting. - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. diff --git a/system_prompts/auto-generated/skills/workspace-settings/SKILL.md b/system_prompts/auto-generated/skills/workspace-settings/SKILL.md index 73f276c255..f739f95aa7 100644 --- a/system_prompts/auto-generated/skills/workspace-settings/SKILL.md +++ b/system_prompts/auto-generated/skills/workspace-settings/SKILL.md @@ -58,7 +58,7 @@ write a secret you were not given: keep the placeholder. | `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. | | `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. | | `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. | -| `guest_access_enabled`, `guest_jwt_public_key`, `guest_jwt_jwks_url` | Guest access to public apps and the key that signs guest JWTs. | +| `guest_jwt_public_key`, `guest_jwt_jwks_url` | The key that signs guest JWTs for apps set to Guests. | | `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. | | `color` | Workspace color, `#rrggbb`. | @@ -76,8 +76,8 @@ With `includeSettings: true` in `wmill.yaml`, `wmill sync pull` writes the docum `wmill sync push` applies it. Unlike a partial apply, a push treats the file as the whole document: a setting the file omits is cleared, except `operator_settings`, `color`, the auto-invite instance groups, and the keys `ducklake`, `dbt_warehouses`, `deploy_ui`, `teams_command_script`, -`public_app_execution_limit_per_minute`, `guest_access_enabled`, `guest_jwt_public_key`, -`guest_jwt_jwks_url`, `error_handler_fallback_to_instance_alerts` and +`public_app_execution_limit_per_minute`, `guest_jwt_public_key`, `guest_jwt_jwks_url`, +`error_handler_fallback_to_instance_alerts` and `add_admins_and_developers_to_forks`, which are managed only when the file states them. `datatable_permissions` is pushed only with `includeDatatablePermissions: true` (or `--include-datatable-permissions`). A push ignores `protection_rules`: `protection-rules.yaml` and diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index fbada8732d..fc1ccbcdf6 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -32,7 +32,7 @@ Add `--description "..."` when the user provided a longer explanation worth pres Open the generated `flow.yaml` (under the folder the command just created) and replace the empty `value.modules` + `schema` with the real flow definition. -For rawscript modules, use `!inline path/to/script.ts` for the content key. Inline script files should NOT include `.inline_script.` in their names (e.g. use `a.ts`, not `a.inline_script.ts`). +For rawscript modules, set the content key to the quoted string `content: '!inline path/to/script.ts'`. Unquoted, `!inline` is a YAML tag, which `wmill lint` rejects. Inline script files should NOT include `.inline_script.` in their names (e.g. use `a.ts`, not `a.inline_script.ts`). Once the flow has real content, **offer** to open the visual preview as a one-sentence next step (e.g. "Want me to open the visual preview?"). Don't auto-open — opening the dev page has side effects (browser window, possibly a `launch.json` entry) and the user should consent. @@ -707,4 +707,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"requires_approval":{"type":"boolean","description":"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"},{"$ref":"#/components/schemas/QuestionToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue","question":"#/components/schemas/QuestionToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"QuestionToolValue":{"type":"object","description":"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.","properties":{"tool_type":{"type":"string","enum":["question"]},"timeout":{"type":"integer","description":"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800."}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["approval"]},"call_id":{"type":"string"},"calls":{"type":"array","items":{"type":"object","properties":{"function_name":{"type":"string"},"arguments":{}},"required":["function_name","arguments"]}},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","calls","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["question"]},"call_id":{"type":"string"},"question":{"type":"string"},"choices":{"type":"array","items":{"type":"string"},"description":"answers the model proposes; a free-text answer is always possible"},"multi_select":{"type":"boolean","description":"whether several choices can be picked together"},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","question","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'"}},"required":["expr"]},"sleep":{"description":"Delay after this step completes, before the next step starts (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"requires_approval":{"type":"boolean","description":"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"},{"$ref":"#/components/schemas/QuestionToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue","question":"#/components/schemas/QuestionToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"QuestionToolValue":{"type":"object","description":"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.","properties":{"tool_type":{"type":"string","enum":["question"]},"timeout":{"type":"integer","description":"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800."}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["approval"]},"call_id":{"type":"string"},"calls":{"type":"array","items":{"type":"object","properties":{"function_name":{"type":"string"},"arguments":{}},"required":["function_name","arguments"]}},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","calls","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["question"]},"call_id":{"type":"string"},"question":{"type":"string"},"choices":{"type":"array","items":{"type":"string"},"description":"answers the model proposes; a free-text answer is always possible"},"multi_select":{"type":"boolean","description":"whether several choices can be picked together"},"expires_at":{"type":"string","format":"date-time"}},"required":["type","call_id","question","expires_at"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} diff --git a/system_prompts/base/flow-chat-special-modules.md b/system_prompts/base/flow-chat-special-modules.md index 42fe741969..17b00034e9 100644 --- a/system_prompts/base/flow-chat-special-modules.md +++ b/system_prompts/base/flow-chat-special-modules.md @@ -1,6 +1,7 @@ ## Special Modules - Use `set_preprocessor_module` to add, replace, or remove the top-level `value.preprocessor_module` +- A preprocessor receives the trigger's `event`, not the flow's inputs: leave its `input_transforms` empty - Use `set_failure_module` to add, replace, or remove the top-level `value.failure_module` - Use `set_flow_json` only when you are replacing the whole flow, including normal modules and optional special modules @@ -12,10 +13,8 @@ set_preprocessor_module({ value: { type: "rawscript", language: "bun", - content: "export async function preprocessor(payload: string) { const trimmed = payload.trim(); if (!trimmed) { throw new Error('payload must not be empty'); } return { payload: trimmed }; }", - input_transforms: { - payload: { type: "javascript", expr: "flow_input.payload" } - } + content: "export async function preprocessor(event: { kind: string; body?: { payload?: string } }) { const payload = String(event.body?.payload ?? '').trim(); if (!payload) { throw new Error('payload must not be empty'); } return { payload }; }", + input_transforms: {} } }) }) diff --git a/system_prompts/base/flow-cli.md b/system_prompts/base/flow-cli.md index fa4ec9cfcc..c3d5bedc2a 100644 --- a/system_prompts/base/flow-cli.md +++ b/system_prompts/base/flow-cli.md @@ -27,7 +27,7 @@ Add `--description "..."` when the user provided a longer explanation worth pres Open the generated `flow.yaml` (under the folder the command just created) and replace the empty `value.modules` + `schema` with the real flow definition. -For rawscript modules, use `!inline path/to/script.ts` for the content key. Inline script files should NOT include `.inline_script.` in their names (e.g. use `a.ts`, not `a.inline_script.ts`). +For rawscript modules, set the content key to the quoted string `content: '!inline path/to/script.ts'`. Unquoted, `!inline` is a YAML tag, which `wmill lint` rejects. Inline script files should NOT include `.inline_script.` in their names (e.g. use `a.ts`, not `a.inline_script.ts`). Once the flow has real content, **offer** to open the visual preview as a one-sentence next step (e.g. "Want me to open the visual preview?"). Don't auto-open — opening the dev page has side effects (browser window, possibly a `launch.json` entry) and the user should consent. diff --git a/system_prompts/base/workspace-settings.md b/system_prompts/base/workspace-settings.md index 145cd89c66..f5e4c7e7d1 100644 --- a/system_prompts/base/workspace-settings.md +++ b/system_prompts/base/workspace-settings.md @@ -53,7 +53,7 @@ write a secret you were not given: keep the placeholder. | `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. | | `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. | | `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. | -| `guest_access_enabled`, `guest_jwt_public_key`, `guest_jwt_jwks_url` | Guest access to public apps and the key that signs guest JWTs. | +| `guest_jwt_public_key`, `guest_jwt_jwks_url` | The key that signs guest JWTs for apps set to Guests. | | `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. | | `color` | Workspace color, `#rrggbb`. | <!-- cli-only --> @@ -72,8 +72,8 @@ With `includeSettings: true` in `wmill.yaml`, `wmill sync pull` writes the docum `wmill sync push` applies it. Unlike a partial apply, a push treats the file as the whole document: a setting the file omits is cleared, except `operator_settings`, `color`, the auto-invite instance groups, and the keys `ducklake`, `dbt_warehouses`, `deploy_ui`, `teams_command_script`, -`public_app_execution_limit_per_minute`, `guest_access_enabled`, `guest_jwt_public_key`, -`guest_jwt_jwks_url`, `error_handler_fallback_to_instance_alerts` and +`public_app_execution_limit_per_minute`, `guest_jwt_public_key`, `guest_jwt_jwks_url`, +`error_handler_fallback_to_instance_alerts` and `add_admins_and_developers_to_forks`, which are managed only when the file states them. `datatable_permissions` is pushed only with `includeDatatablePermissions: true` (or `--include-datatable-permissions`). A push ignores `protection_rules`: `protection-rules.yaml` and diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index e9d44f60cb..5c0dd2fe5d 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.851.0", + "version": "1.853.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 8862611ff9..55646cc0e6 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.851.0", + "version": "1.853.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 4b22e55cc6..0a49390018 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.851.0 +1.853.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index f3954dd710..4651099c5a 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.851.0", + "version": "1.853.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.851.0", + "version": "1.853.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 0375557f23..313228c053 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.851.0", + "version": "1.853.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts",