From cd4e55de7d1760ceaeafe0306b1d1555385f34fb Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 26 Oct 2026 18:06:25 +0100 Subject: [PATCH 01/14] force a fresh apt-get upgrade in docker images via APT_REFRESH (#12026) Co-authored-by: Claude Opus 5.5 --- Dockerfile | 8 ++++++-- docker/DockerfileSlim | 5 ++++- docker/DockerfileSlimEe | 5 ++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 93862fe0b9..7d53a5a58a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -169,8 +169,12 @@ ENV UV_TOOL_DIR=/usr/local/uv ENV PATH /usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH - -RUN apt-get update \ +# The build cache keeps the apt layer while the base image digest and this line are unchanged, so +# Debian security updates are not picked up by later builds. Bump to force a fresh apt-get upgrade; +# bump it in docker/DockerfileSlim and docker/DockerfileSlimEe too. +ARG APT_REFRESH=1 +RUN echo "apt refresh ${APT_REFRESH}" \ + && apt-get update \ && apt-get upgrade -y \ && apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 tini gnupg libargon2-1 \ && if echo "$features" | grep -q "ee"; then apt-get install -y --no-install-recommends libsasl2-modules-gssapi-mit krb5-user; fi \ diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 0a1e5a0c42..daeb64b637 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -44,7 +44,10 @@ ENV UV_TOOL_DIR=/usr/local/uv ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies -RUN apt-get update \ +# Bump to force a fresh apt-get upgrade past the build cache; keep in step with ../Dockerfile. +ARG APT_REFRESH=1 +RUN echo "apt refresh ${APT_REFRESH}" \ + && apt-get update \ && apt-get upgrade -y \ && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30t64 libgcrypt20 \ && apt-get clean \ diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index 46d0d2d98f..3edff33038 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -44,7 +44,10 @@ ENV UV_TOOL_DIR=/usr/local/uv ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies -RUN apt-get update \ +# Bump to force a fresh apt-get upgrade past the build cache; keep in step with ../Dockerfile. +ARG APT_REFRESH=1 +RUN echo "apt refresh ${APT_REFRESH}" \ + && apt-get update \ && apt-get upgrade -y \ && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30t64 libgcrypt20 \ && apt-get clean \ From ee4150a69cca745ce3a909cefa5b9c1627a5f992 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 26 Oct 2026 18:25:47 +0100 Subject: [PATCH 02/14] fix: serve new MCP OAuth sign-ins the session toolset on the tools server URLs (#12024) * fix: serve new MCP OAuth sign-ins the session toolset on /api/mcp/gateway Co-Authored-By: Claude Opus 5.5 * fix: keep the mcp:session marker across every token scope edit Co-Authored-By: Claude Opus 5.5 * fix: serve marked MCP sign-ins the session toolset on the per-workspace URL too Co-Authored-By: Claude Opus 5.5 * fix: let a restricted caller re-save a token's mcp:session marker Co-Authored-By: Claude Opus 5.5 * fix: preselect build and run when an MCP client requests every scope Co-Authored-By: Claude Opus 5.5 * fix: advertise only granting MCP scopes in protected resource metadata Co-Authored-By: Claude Opus 5.5 * fix: apply the blanket-request default on the consent page only Co-Authored-By: Claude Opus 5.5 * fix: treat only an mcp:all request with both narrowings as blanket Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 2 +- backend/windmill-api-auth/src/lib.rs | 9 ++- backend/windmill-api/src/mcp/core.rs | 72 ++++++++++++++++--- backend/windmill-api/src/mcp/oauth_server.rs | 37 ++++++---- backend/windmill-mcp/src/common/mod.rs | 2 +- backend/windmill-mcp/src/common/scope.rs | 9 +++ .../lib/components/mcp/McpAccessPicker.svelte | 8 ++- .../settings/EditTokenScopesModal.svelte | 9 ++- .../components/settings/TokensTable.svelte | 9 ++- .../oauth/mcp_authorize/+page@(root).svelte | 9 ++- 10 files changed, 133 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 835c8a4edb..ef87f1c4dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does. - **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags. - **AI guidance** (chat prompts and CLI skills): what the AI knows about Windmill itself — how flows, scripts, apps, pipelines and resources work — is written once, in `system_prompts/base/` and `system_prompts/languages/`, and `system_prompts/generate.py` builds both the chat's `$system_prompts` exports and the CLI's skills from it. Add or change such guidance there, never inline in one consumer: a sentence only one side should see goes in a `` / `` block, and a new file reaches both through the `TOPICS` table. The chat prompt builders under `frontend/src/lib/components/copilot/chat/` add only tool plumbing and runtime values, and shared markdown names no chat tool, since it reaches every chat mode. Rerun `generate.py` after editing; `system_prompts/README.md` has the details. -- **Windmill MCP server**: `/api/mcp` gives a token with `mcp:all` the AI session's GLOBAL toolset (`backend/windmill-api/src/mcp/session/`), and a token scoped to a selection of scripts and flows the tools server's per-runnable tools, without endpoint tools. The tools server's own URLs, `/api/mcp/w/{workspace}/mcp` and `/api/mcp/gateway`, keep serving existing tokens, endpoint scopes included. Every tool takes a `workspace_id`; the token bounds which (its bound workspace, the `mcp:workspaces:` list, or all of its owner's). What a model reads there (tool names, descriptions, input schemas, instructions, `get_instructions` texts) is generated from the session's TypeScript into `session/gen/`: after changing a GLOBAL tool definition, the global system prompt or `system_prompts/`, run `npm run gen:mcp-session` from `frontend/` (`mcpSessionGen.test.ts` fails otherwise). What a tool *does* is written twice, in `chat/global/core.ts` and in a Rust handler: a behaviour change on one side must be mirrored on the other, and a new session tool is either implemented in Rust or added to `EDITOR_ONLY` (a `cargo test` in `session/mod.rs` fails otherwise). +- **Windmill MCP server**: `/api/mcp` gives a token with `mcp:all` the AI session's GLOBAL toolset (`backend/windmill-api/src/mcp/session/`), and a token scoped to a selection of scripts and flows the tools server's per-runnable tools, without endpoint tools. The tools server's own URLs, `/api/mcp/w/{workspace}/mcp` and `/api/mcp/gateway`, keep serving existing tokens, endpoint scopes included, except that they give the session toolset to an `mcp:all` token carrying `mcp:session` (added by the OAuth approve, kept across scope edits) or `mcp:read_only`/`mcp:read_run`. Every tool takes a `workspace_id`; the token bounds which (its bound workspace, the `mcp:workspaces:` list, or all of its owner's). What a model reads there (tool names, descriptions, input schemas, instructions, `get_instructions` texts) is generated from the session's TypeScript into `session/gen/`: after changing a GLOBAL tool definition, the global system prompt or `system_prompts/`, run `npm run gen:mcp-session` from `frontend/` (`mcpSessionGen.test.ts` fails otherwise). What a tool *does* is written twice, in `chat/global/core.ts` and in a Rust handler: a behaviour change on one side must be mirrored on the other, and a new session tool is either implemented in Rust or added to `EDITOR_ONLY` (a `cargo test` in `session/mod.rs` fails otherwise). - **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise). - **Raw-app policy**: `frontend/src/lib/components/raw_apps/rawAppPolicy.ts` also derives the policy the server's raw-app deploy stores, vendored into the bundle job as `backend/windmill-api/src/apps_raw_policy.gen.js`. After changing it or anything it imports, run `bun run gen:app-policy` from `cli/` (`cli/test/app_policy_bundle_unit.test.ts` fails otherwise). It rides in the job rather than being read from the CLI the job runs because the images install `windmill-cli` unpinned, so an image can carry one older than its server. diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 82a40377a0..6a09710b5e 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -591,7 +591,9 @@ pub fn ensure_scopes_within_caller( for requested in requested_restrictions { if requested.starts_with("mcp:") { - if !caller_mcp.contains(requested.as_str()) { + // `mcp:session` (windmill_mcp's SESSION_SCOPE) grants nothing: it only picks + // the toolset an `mcp:all` token gets, and `mcp:all` is matched here itself. + if requested != "mcp:session" && !caller_mcp.contains(requested.as_str()) { return Err(Error::PermissionDenied(format!( "A scope-restricted token cannot grant MCP scope '{requested}' unless the \ caller holds the same scope verbatim" @@ -1964,6 +1966,11 @@ mod tests { opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref() ) .is_err()); + assert!(ensure_scopes_within_caller( + &mcp_all, + opt_scopes(Some(vec!["mcp:all", "mcp:session"])).as_deref() + ) + .is_ok()); } #[test] diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index d31234b585..52a6a32014 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -35,6 +35,7 @@ use std::sync::Arc; use std::time::Duration; use tokio_util::sync::CancellationToken; +use windmill_mcp::common::SESSION_SCOPE; use windmill_mcp::server::{ LocalSessionManager, McpToken, MultiWorkspaceMcp, NoEndpointTools, Runner, SessionRunner, StreamableHttpServerConfig, StreamableHttpService, @@ -503,6 +504,17 @@ fn scoped_to_selection(scopes: &[String]) -> bool { mcp.clone().next().is_some() && !mcp.any(|scope| scope == "mcp:all") } +/// Whether the tools server's URLs (`/api/mcp/gateway`, `/api/mcp/w/{workspace}/mcp`) serve +/// a token the session's toolset: an `mcp:all` grant marked `mcp:session`, or narrowed by +/// `mcp:read_only` / `mcp:read_run`, which the consent page describes as session access. +/// Every other token there keeps the tools server it was issued for. +fn serves_session_toolset(scopes: &[String]) -> bool { + scopes.iter().any(|scope| scope == "mcp:all") + && scopes + .iter() + .any(|scope| [SESSION_SCOPE, "mcp:read_only", "mcp:read_run"].contains(&scope.as_str())) +} + /// The path of a workspace-less MCP URL, when it is not the tools server's /// `/api/mcp/gateway`: each URL is its own OAuth protected resource. #[derive(Clone, Copy)] @@ -519,6 +531,16 @@ pub async fn extract_and_store_workspace_id( next.run(request).await } +/// RFC 9728 challenge pointing at the endpoint's protected resource metadata. `scope` is what +/// a client requests: without it, Claude requests every scope the metadata lists, read-only +/// included, and the consent page would then default to read-only access. +fn www_authenticate_value(base_url: &str, endpoint: &str) -> String { + format!( + "Bearer resource_metadata=\"{base_url}/.well-known/oauth-protected-resource{endpoint}\", \ + scope=\"mcp:all\"" + ) +} + /// Middleware that adds WWW-Authenticate header to 401 responses /// This helps MCP clients discover the OAuth authorization server (RFC 9728) pub async fn add_www_authenticate_header( @@ -545,11 +567,8 @@ pub async fn add_www_authenticate_header( if response.status() == StatusCode::UNAUTHORIZED { let base_url = BASE_URL.load(); - // RFC 9728: The resource parameter contains the protected resource URL. - // Clients derive the metadata URL by inserting /.well-known/oauth-protected-resource - // after the host, e.g., http://host/.well-known/oauth-protected-resource/api/mcp/w/test/mcp - let resource_url = format!("{}/api/mcp/w/{}/mcp", base_url, workspace_id); - let www_authenticate = format!("Bearer resource=\"{}\"", resource_url); + let www_authenticate = + www_authenticate_value(&base_url, &format!("/api/mcp/w/{}/mcp", workspace_id)); // Reconstruct response with the new header let (mut parts, body) = response.into_parts(); @@ -678,8 +697,7 @@ pub async fn add_www_authenticate_header_gateway( if response.status() == StatusCode::UNAUTHORIZED { let base_url = BASE_URL.load(); - let resource_url = format!("{}{}", base_url, endpoint); - let www_authenticate = format!("Bearer resource=\"{}\"", resource_url); + let www_authenticate = www_authenticate_value(&base_url, endpoint); let (mut parts, body) = response.into_parts(); parts.headers.insert( @@ -735,7 +753,30 @@ pub async fn setup_mcp_server( let service = StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config); - let router = Router::new().route_service("/", service.clone()); + let router = Router::new().route( + "/", + axum::routing::any({ + let (service, session_service) = (service.clone(), session_service.clone()); + move |request: Request| { + let (service, session_service) = (service.clone(), session_service.clone()); + async move { + use tower::ServiceExt; + let session = request.extensions().get::().is_some_and(|a| { + serves_session_toolset(a.scopes.as_deref().unwrap_or_default()) + }); + let response = if session { + session_service.oneshot(request).await + } else { + service.oneshot(request).await + }; + match response { + Ok(response) => response.into_response(), + Err(never) => match never {}, + } + } + } + }), + ); // `/api/mcp` serves both servers: a token scoped to a selection of scripts, flows and // endpoints gets the tools server, any other the session's toolset. At `/mcp` itself, // beside the `/mcp/...` routes, so it is merged rather than nested. @@ -810,7 +851,7 @@ pub fn toolset_service() -> Router { #[cfg(test)] mod tests { - use super::scoped_to_selection; + use super::{scoped_to_selection, serves_session_toolset}; #[test] fn only_a_token_without_mcp_all_is_a_selection() { @@ -829,4 +870,17 @@ mod tests { assert!(!selection(&[])); assert!(!selection(&["variables:read"])); } + + #[test] + fn tools_server_urls_keep_unmarked_tokens() { + let session = |scopes: &[&str]| { + serves_session_toolset(&scopes.iter().map(|s| s.to_string()).collect::>()) + }; + assert!(session(&["mcp:all", "mcp:session"])); + assert!(session(&["mcp:all", "mcp:read_only"])); + assert!(session(&["mcp:all", "mcp:read_run", "mcp:workspaces:a,b"])); + assert!(!session(&["mcp:all"])); + assert!(!session(&["mcp:favorites", "mcp:session"])); + assert!(!session(&["mcp:all:f/x/*", "mcp:read_only"])); + } } diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs index de07647305..677875da96 100644 --- a/backend/windmill-api/src/mcp/oauth_server.rs +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -19,7 +19,7 @@ use windmill_common::{ use crate::db::ApiAuthed; use windmill_api_auth::forbid_elevated_job_token; -use windmill_mcp::parse_mcp_scopes; +use windmill_mcp::{common::SESSION_SCOPE, parse_mcp_scopes}; /// Token expiration for MCP OAuth tokens (1 week in seconds) const MCP_OAUTH_TOKEN_EXPIRATION_SECS: u64 = 7 * 24 * 60 * 60; @@ -225,17 +225,25 @@ struct RefreshTokenRow { revoked: bool, } -fn supported_scopes() -> Vec { - vec![ - "mcp:all".to_string(), - "mcp:favorites".to_string(), - "mcp:scripts:*".to_string(), - "mcp:flows:*".to_string(), - "mcp:endpoints:*".to_string(), - "mcp:read_only".to_string(), - "mcp:read_run".to_string(), - "mcp:runnables_as_tools".to_string(), +/// The scopes that grant access. A protected resource advertises only these: MCP clients +/// that request every listed scope must not pick up a narrowing, which would make the +/// consent page start from a narrower access than they meant to ask for. +fn granting_scopes() -> Vec { + [ + "mcp:all", + "mcp:favorites", + "mcp:scripts:*", + "mcp:flows:*", + "mcp:endpoints:*", ] + .map(String::from) + .to_vec() +} + +fn supported_scopes() -> Vec { + let mut scopes = granting_scopes(); + scopes.extend(["mcp:read_only", "mcp:read_run", "mcp:runnables_as_tools"].map(String::from)); + scopes } /// GET /.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server @@ -263,7 +271,7 @@ fn build_protected_resource_metadata( ProtectedResourceMetadata { resource: format!("{}{}", base_url, resource_path), authorization_servers: vec![format!("{}{}", base_url, oauth_prefix)], - scopes_supported: Some(supported_scopes()), + scopes_supported: Some(granting_scopes()), bearer_methods_supported: Some(vec!["header".to_string()]), } } @@ -873,11 +881,14 @@ async fn oauth_approve_inner( let code = format!("mcp-code-{}", rd_string(32)); - let scopes: Vec = form + let mut scopes: Vec = form .scope .split_whitespace() .map(|s| s.to_string()) .collect(); + if scopes.iter().any(|s| s == "mcp:all") && !scopes.iter().any(|s| s == SESSION_SCOPE) { + scopes.push(SESSION_SCOPE.to_string()); + } // The approver's own token bounds what it may grant: a scope-restricted MCP // token must not approve a broader one (e.g. mcp:scripts:f/x -> mcp:all). An diff --git a/backend/windmill-mcp/src/common/mod.rs b/backend/windmill-mcp/src/common/mod.rs index 2632a28962..6707943482 100644 --- a/backend/windmill-mcp/src/common/mod.rs +++ b/backend/windmill-mcp/src/common/mod.rs @@ -9,7 +9,7 @@ pub mod transform; pub mod types; pub use schema::convert_schema_to_schema_type; -pub use scope::{is_resource_allowed, parse_mcp_scopes, McpScopeConfig}; +pub use scope::{is_resource_allowed, parse_mcp_scopes, McpScopeConfig, SESSION_SCOPE}; pub use transform::{ apply_key_transformation, extract_hub_version_id_from_hashed, extract_path_prefix_from_hashed, parse_tool_prefix, reverse_transform, reverse_transform_key, transform_hub_path, diff --git a/backend/windmill-mcp/src/common/scope.rs b/backend/windmill-mcp/src/common/scope.rs index 42327215ae..642c02a2bc 100644 --- a/backend/windmill-mcp/src/common/scope.rs +++ b/backend/windmill-mcp/src/common/scope.rs @@ -161,6 +161,11 @@ fn pattern_covers(caller: &str, requested: &str) -> bool { /// URL, which an older selection, carrying none of the newer scopes, still does. pub const RUNNABLES_AS_TOOLS_SCOPE: &str = "mcp:runnables_as_tools"; +/// Added server-side to an `mcp:all` OAuth grant so the tools server's URLs serve it the AI +/// session's toolset. Grants nothing: an un-narrowed `mcp:all` token without it predates the +/// session toolset there, and must keep the tools server's endpoint tools. +pub const SESSION_SCOPE: &str = "mcp:session"; + /// Parse MCP scopes from token scope strings pub fn parse_mcp_scopes(scopes: &[String]) -> Result { let mut config = McpScopeConfig::default(); @@ -233,6 +238,10 @@ pub fn parse_mcp_scopes(scopes: &[String]) -> Result { continue; } + if scope == SESSION_SCOPE { + continue; + } + if let Some(ids) = scope.strip_prefix("mcp:workspaces:") { config .workspaces diff --git a/frontend/src/lib/components/mcp/McpAccessPicker.svelte b/frontend/src/lib/components/mcp/McpAccessPicker.svelte index c80ab00ed2..6466bb8761 100644 --- a/frontend/src/lib/components/mcp/McpAccessPicker.svelte +++ b/frontend/src/lib/components/mcp/McpAccessPicker.svelte @@ -4,6 +4,10 @@ /** Marks a selection, so the per-workspace URLs add no API endpoint tools to it. */ export const RUNNABLES_AS_TOOLS = 'mcp:runnables_as_tools' + /** Added by the server to an `mcp:all` sign-in so the tools server's URLs serve it the AI + * session's tools. Grants nothing, so it is never shown. */ + export const MCP_SESSION = 'mcp:session' + const ACCESS_SCOPES: Record, string[]> = { read: ['mcp:all', 'mcp:read_only'], read_run: ['mcp:all', 'mcp:read_run'], @@ -13,7 +17,7 @@ /** The access a token's MCP scopes give: `mcp:all` and how it is narrowed, or else a * selection of scripts, flows and endpoints. */ export function mcpAccessOf(scopes: string[]): McpAccess { - scopes = scopes.filter((s) => s !== RUNNABLES_AS_TOOLS) + scopes = scopes.filter((s) => s !== RUNNABLES_AS_TOOLS && s !== MCP_SESSION) if (!scopes.includes('mcp:all')) return scopes.length > 0 ? 'selected' : 'full' if (scopes.includes('mcp:read_only')) return 'read' if (scopes.includes('mcp:read_run')) return 'read_run' @@ -50,7 +54,7 @@ access = initialAccess const initialSelection = initialAccess === 'selected' - ? initialScopes.filter((s) => s !== RUNNABLES_AS_TOOLS).join(' ') + ? initialScopes.filter((s) => s !== RUNNABLES_AS_TOOLS && s !== MCP_SESSION).join(' ') : undefined let selection = $state(initialSelection ?? 'mcp:favorites') diff --git a/frontend/src/lib/components/settings/EditTokenScopesModal.svelte b/frontend/src/lib/components/settings/EditTokenScopesModal.svelte index 7125093749..46ad5b4720 100644 --- a/frontend/src/lib/components/settings/EditTokenScopesModal.svelte +++ b/frontend/src/lib/components/settings/EditTokenScopesModal.svelte @@ -6,7 +6,7 @@ import Modal from '../common/modal/Modal.svelte' import TextInput from '../text_input/TextInput.svelte' import ScopesPicker from './ScopesPicker.svelte' - import McpAccessPicker, { mcpAccessOf } from '../mcp/McpAccessPicker.svelte' + import McpAccessPicker, { MCP_SESSION, mcpAccessOf } from '../mcp/McpAccessPicker.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -50,10 +50,13 @@ // user has used the picker; until then the token keeps exactly the scopes it had. let mcpTouched = $state(false) // Carried over as is, the picker knowing nothing of them: dropping the workspace list - // would widen the token to every workspace. + // would widen the token to every workspace, dropping `mcp:session` would move a sign-in + // back to the endpoint tools once it is given `mcp:all` again. const isWorkspaceList = (s: string) => s.startsWith('mcp:workspaces:') const keptAside = (s: string) => - isWorkspaceList(s) || (mcpAccessOf(mcpScopes) !== 'selected' && s.startsWith('mcp:hub:')) + isWorkspaceList(s) || + s === MCP_SESSION || + (mcpAccessOf(mcpScopes) !== 'selected' && s.startsWith('mcp:hub:')) const scopesToSave = $derived( isMcp ? mcpTouched diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index a13c06312b..3cd83e7d89 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -10,6 +10,7 @@ import Badge from '../common/badge/Badge.svelte' import Alert from '../common/alert/Alert.svelte' import { Pen, Trash } from 'lucide-svelte' + import { MCP_SESSION } from '../mcp/McpAccessPicker.svelte' // --- Props --- interface Props { @@ -114,6 +115,10 @@ listTokens() } + function shownScopes(scopes: string[] | null | undefined): string { + return (scopes ?? []).filter((s) => s !== MCP_SESSION).join(', ') + } + function handleEditClick( tokenPrefix: string, tokenLabel: string | undefined, @@ -199,13 +204,13 @@
{#if read_only} Read-only {/if} - {scopes?.join(', ') ?? ''} + {shownScopes(scopes)}
diff --git a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte index a917de4d4a..3daaeba05c 100644 --- a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte +++ b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte @@ -20,9 +20,16 @@ let clientName = page.url.searchParams.get('client_name') || 'Unknown Client' let redirectUri = page.url.searchParams.get('redirect_uri') || '' // What the client asked for seeds the access picker; what is approved is the picker's. - const requestedScopes = (page.url.searchParams.get('scope') || 'mcp:all') + const askedScopes = (page.url.searchParams.get('scope') || 'mcp:all') .split(' ') .filter((s) => s.startsWith('mcp:')) + // Both narrowings at once is a client requesting every scope it was offered, not a choice. + const requestedScopes = + askedScopes.includes('mcp:all') && + askedScopes.includes('mcp:read_only') && + askedScopes.includes('mcp:read_run') + ? askedScopes.filter((s) => s !== 'mcp:read_only' && s !== 'mcp:read_run') + : askedScopes let scopes = $state([]) let access = $state() let oauthState = page.url.searchParams.get('state') || '' From a2b91fd17e785f4c707eea092bc0b993419d7d7d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 26 Oct 2026 19:20:58 +0100 Subject: [PATCH 03/14] fix: detect attached -c and --no-config in DENO_FLAGS (#12027) * fix: detect attached -c and --no-config in DENO_FLAGS Co-Authored-By: Claude Opus 5.5 * fix: keep tracing span on handle_deno_job Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/windmill-worker/src/deno_executor.rs | 48 ++++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index de2098392e..82eae514f5 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -8,9 +8,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, deno_not_installed_error, - get_reserved_variables, - parse_npm_config, read_file, read_result, start_child_process, OccupancyMetrics, - StreamNotifier, + get_reserved_variables, parse_npm_config, read_file, read_result, start_child_process, + OccupancyMetrics, StreamNotifier, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -278,6 +277,15 @@ async fn refuse_links_in_shared_dir(job_dir: &str) -> error::Result<()> { Ok(()) } +// An admin may pass a config of their own, or --no-config, through DENO_FLAGS; deno +// refuses --config next to --no-config or a second --config, so it then stands on its +// own. `-c` also takes its value attached (`-c/path`, `-c=/path`). +fn deno_flags_choose_config(flags: &[String]) -> bool { + flags.iter().any(|f| { + f.starts_with("-c") || f == "--config" || f.starts_with("--config=") || f == "--no-config" + }) +} + #[tracing::instrument(level = "trace", skip_all)] pub async fn handle_deno_job( requirements_o: Option<&String>, @@ -508,13 +516,9 @@ try {{ deno_config_path = Some(format!("{job_dir}/deno.json")); } } - // An admin may pass a config of their own through DENO_FLAGS; deno refuses it next - // to --no-config or a second --config, so it then stands on its own. - let config_in_deno_flags = DENO_FLAGS.as_ref().is_some_and(|flags| { - flags - .iter() - .any(|f| f == "-c" || f == "--config" || f.starts_with("--config=")) - }); + let config_in_deno_flags = DENO_FLAGS + .as_ref() + .is_some_and(|flags| deno_flags_choose_config(flags)); //do not cache local dependencies let child = { @@ -899,3 +903,27 @@ pub async fn start_worker( ) .await } + +#[cfg(test)] +mod tests { + use super::deno_flags_choose_config; + + fn flags(s: &str) -> Vec { + s.split(' ').map(str::to_string).collect() + } + + #[test] + fn deno_flags_config_detection() { + for f in [ + "-A -c /etc/deno.json", + "-A -c/etc/deno.json", + "-A -c=/etc/deno.json", + "-A --config /etc/deno.json", + "-A --config=/etc/deno.json", + "-A --no-config", + ] { + assert!(deno_flags_choose_config(&flags(f)), "{f}"); + } + assert!(!deno_flags_choose_config(&flags("-A --cached-only"))); + } +} From a1bc9ec0c27ab615460b145e59d7e7b8fdea8406 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 26 Oct 2026 19:22:45 +0100 Subject: [PATCH 04/14] feat: tool approval and a question tool for ai agent steps (#11826) * feat(ai-agent): built-in approval tool that parks the agent An AI agent step can now be given a built-in "Request approval" tool. The model calls `request_approval` with a message; the agent stores its conversation in the new `ai_agent_checkpoint` table, releases its worker by parking its own queue row like a workflow-as-code parent, and resumes with the answer (approved, approver, comment) or a timeout once someone answers from the run page. Co-Authored-By: Claude Opus 5.5 * fix(ai-agent): tie approval answers to their request and hold the turn - A turn that asks for approval runs none of its other calls; they are refused so the model repeats them after the answer. - The run page sends the request's call id. An answer naming another request is ignored and the agent parks again for the time left; `suspend` is zeroed when the answer is read, so answers sent while the agent runs are refused. - The checkpoint is read whatever the current roster holds, the answer's outcome is written by position, and a cancel that beats the park completes the job as cancelled. Co-Authored-By: Claude Opus 5.5 * fix(ai-agent): say why calls beside an invalid approval request were refused Co-Authored-By: Claude Opus 5.5 * fix(ai-agent): one approval form per request, none on a finished run Co-Authored-By: Claude Opus 5.5 * feat(ai-agent): answer approval requests from a flow's chat Co-Authored-By: Claude Opus 5.5 * fix(ai-agent): show chat approvals for agents that do not stream Co-Authored-By: Claude Opus 5.5 * feat(ai-agent): approval as a per-tool setting, built-in tool asks a question Co-Authored-By: Claude Opus 5.5 * fix(ai-agent): say when a nested agent leaves out tools that require approval Co-Authored-By: Claude Opus 5.5 * feat(ai-agent): show chat approvals and questions on the tool's own card Co-Authored-By: Claude Opus 5.5 * feat(ai-agent): question choices, and approval buttons shared with approval steps Co-Authored-By: Claude Opus 5.5 * feat(ai-agent): show a waiting agent as suspended, and refused calls as not run Co-Authored-By: Claude Opus 5.5 * fix(ai-agent): each added chat card shows its own call, answered marker keyed by job Co-Authored-By: Claude Opus 5.5 * test(ai-agent): approve and reject a held call through resume_suspended Co-Authored-By: Claude Opus 5.5 * feat(ai-agent): require approval in a tool settings tab, plain descriptions on built-in tool panes Co-Authored-By: Claude Opus 5.5 * fix(ai-agent): sentence case for the web search tool label Co-Authored-By: Claude Opus 5.5 * feat(ai-agent): built-in tool drawers explain themselves in the header subtitle Co-Authored-By: Claude Opus 5.5 * fix(ai-agent): clearer question tool subtitle Co-Authored-By: Claude Opus 5.5 * fix(ai-agent): badge the tool settings tab like run settings Co-Authored-By: Claude Opus 5.5 * chore(ai-agent): regenerate system prompts and MCP session files Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...0261016162101_ai_agent_checkpoint.down.sql | 1 + .../20261016162101_ai_agent_checkpoint.up.sql | 9 + backend/src/monitor.rs | 19 + backend/summarized_schema.txt | 1 + backend/tests/ai_agent_approval.rs | 154 +++++++ backend/windmill-ai/src/types.rs | 5 + .../src/mcp/session/gen/instructions.json | 2 +- backend/windmill-types/src/flow_status.rs | 25 ++ backend/windmill-types/src/flows.rs | 23 +- backend/windmill-worker/src/ai/compaction.rs | 63 +++ backend/windmill-worker/src/ai/mod.rs | 1 + backend/windmill-worker/src/ai/pause.rs | 377 ++++++++++++++++++ .../src/ai/stream_event_processor.rs | 8 +- backend/windmill-worker/src/ai/tools.rs | 236 ++++++++++- backend/windmill-worker/src/ai/utils.rs | 36 ++ backend/windmill-worker/src/ai_executor.rs | 302 ++++++++++++-- chat-sdk/README.md | 1 + chat-sdk/src/chat.ts | 2 + chat-sdk/src/types.ts | 2 + cli/src/commands/lint/flow_semantics.ts | 16 +- cli/src/guidance/skills.gen.ts | 13 +- docs/reusable-ai-agents.md | 45 +++ .../components/AgentApprovalRequest.svelte | 122 ++++++ .../components/FlowStatusViewerInner.svelte | 26 ++ .../FlowStatusWaitingForEvents.svelte | 80 ++-- frontend/src/lib/components/JobStatus.svelte | 3 +- frontend/src/lib/components/agentRequest.ts | 60 +++ frontend/src/lib/components/agentTrace.ts | 9 + .../approvals/ApprovalActions.svelte | 76 ++++ .../copilot/chat/flow/openFlow.json | 2 +- .../copilot/chat/flow/openFlowZod.gen.ts | 8 +- .../components/flows/FlowProgressBar.svelte | 9 +- .../lib/components/flows/agentDraft.svelte.ts | 10 +- .../src/lib/components/flows/agentToolTree.ts | 7 +- .../components/flows/agentToolUtils.test.ts | 7 + .../lib/components/flows/agentToolUtils.ts | 41 +- .../flows/content/AgentConfigModal.svelte | 10 +- .../flows/content/AgentToolRoster.svelte | 18 +- .../flows/content/AgentToolWrapper.svelte | 6 + .../flows/content/FlowModuleComponent.svelte | 38 +- .../flows/content/McpToolEditor.svelte | 14 + .../flows/content/QuestionToolDisplay.svelte | 32 ++ .../flows/content/WebsearchToolDisplay.svelte | 15 +- .../conversations/FlowChatInterface.svelte | 52 ++- .../conversations/flowChatViewHost.svelte.ts | 128 +++++- .../conversations/flowChatViewHost.test.ts | 45 +++ .../components/flows/flowStateUtils.svelte.ts | 4 + .../flows/map/InsertModuleInner.svelte | 12 +- .../flows/pickers/TopLevelNode.svelte | 4 +- .../components/graph/graphBuilder.svelte.ts | 1 + .../graph/renderers/nodes/AIToolNode.svelte | 55 ++- .../graph/renderers/nodes/ModuleNode.svelte | 9 +- .../renderers/nodes/NewAIToolNode.svelte | 8 + .../runs/FlowExecutionStatus.svelte | 15 +- .../lib/components/runs/JobStatusIcon.svelte | 3 +- openflow.openapi.yaml | 72 +++- system_prompts/auto-generated/flow.md | 13 +- system_prompts/auto-generated/prompts.ts | 13 +- .../auto-generated/skills/write-flow/SKILL.md | 13 +- system_prompts/base/flow-base.md | 11 +- 60 files changed, 2193 insertions(+), 199 deletions(-) create mode 100644 backend/migrations/20261016162101_ai_agent_checkpoint.down.sql create mode 100644 backend/migrations/20261016162101_ai_agent_checkpoint.up.sql create mode 100644 backend/tests/ai_agent_approval.rs create mode 100644 backend/windmill-worker/src/ai/pause.rs create mode 100644 frontend/src/lib/components/AgentApprovalRequest.svelte create mode 100644 frontend/src/lib/components/agentRequest.ts create mode 100644 frontend/src/lib/components/approvals/ApprovalActions.svelte create mode 100644 frontend/src/lib/components/flows/content/QuestionToolDisplay.svelte diff --git a/backend/migrations/20261016162101_ai_agent_checkpoint.down.sql b/backend/migrations/20261016162101_ai_agent_checkpoint.down.sql new file mode 100644 index 0000000000..002ce5d21d --- /dev/null +++ b/backend/migrations/20261016162101_ai_agent_checkpoint.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ai_agent_checkpoint; diff --git a/backend/migrations/20261016162101_ai_agent_checkpoint.up.sql b/backend/migrations/20261016162101_ai_agent_checkpoint.up.sql new file mode 100644 index 0000000000..a4407190b0 --- /dev/null +++ b/backend/migrations/20261016162101_ai_agent_checkpoint.up.sql @@ -0,0 +1,9 @@ +-- The conversation of an AI agent step parked on its approval tool, read back when the job is +-- pulled again. No foreign key to v2_job_queue: one would lock that table on every write here. +-- The monitor deletes rows whose job left the queue. +CREATE TABLE IF NOT EXISTS ai_agent_checkpoint ( + job_id UUID PRIMARY KEY, + workspace_id VARCHAR(50) NOT NULL, + checkpoint JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 868e558f8d..a27f9d2055 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -4172,6 +4172,9 @@ pub async fn monitor_db( if let Err(e) = cleanup_flow_iterator_data_orphaned_jobs(&db).await { tracing::error!("Error cleaning up flow_iterator_data: {:?}", e); } + if let Err(e) = cleanup_ai_agent_checkpoint_orphaned_jobs(&db).await { + tracing::error!("Error cleaning up ai_agent_checkpoint: {:?}", e); + } } } }; @@ -7729,6 +7732,22 @@ async fn cleanup_job_result_stream_orphaned_jobs(db: &DB) -> error::Result<()> { Ok(()) } +/// A parked agent cancelled from outside, or a worker that died mid-segment, never deletes its +/// checkpoint itself. +async fn cleanup_ai_agent_checkpoint_orphaned_jobs(db: &DB) -> error::Result<()> { + let deleted = sqlx::query( + "DELETE FROM ai_agent_checkpoint c + WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = c.job_id)", + ) + .execute(db) + .await? + .rows_affected(); + if deleted > 0 { + tracing::info!("Cleaned up {deleted} orphaned ai_agent_checkpoint rows"); + } + Ok(()) +} + async fn cleanup_flow_iterator_data_orphaned_jobs(db: &DB) -> error::Result<()> { let result = sqlx::query!( " diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 418108cd30..9b89ad2561 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -37,6 +37,7 @@ _sqlx_migrations: version(bigint), description(text), installed_on(ts), success( account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), client(char), refresh_error(text), grant_type(char), cc_client_id(char), cc_client_secret(char), cc_token_url(char), mcp_server_url(text), scopes(text[]) FK: (workspace_id) -> workspace(id) agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char) +ai_agent_checkpoint: job_id(uuid), workspace_id(char), checkpoint(jsonb), created_at(ts) ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts) ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts) ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts) diff --git a/backend/tests/ai_agent_approval.rs b/backend/tests/ai_agent_approval.rs new file mode 100644 index 0000000000..9c5c00a7f7 --- /dev/null +++ b/backend/tests/ai_agent_approval.rs @@ -0,0 +1,154 @@ +use axum::{routing::post, Json, Router}; +use serde_json::{json, Value}; +use sqlx::{types::Uuid, Pool, Postgres}; +use std::time::Duration; +use windmill_common::{flows::FlowValue, jobs::JobPayload}; +use windmill_test_utils::{ + completed_job, in_test_worker, listen_for_completed_jobs, ApiServer, RunJob, StreamFind, +}; + +async fn model(Json(body): Json) -> ([(&'static str, &'static str); 1], String) { + let answered = body["messages"] + .as_array() + .unwrap() + .iter() + .any(|m| m["role"] == "tool"); + let delta = if answered { + json!({"role":"assistant", "content":"done"}) + } else { + json!({"role":"assistant", "tool_calls":[{ + "index":0, "id":"call_0", "type":"function", + "function":{"name":"delete_database", "arguments":"{}"} + }]}) + }; + let event = json!({"choices":[{"index":0, "delta":delta, "finish_reason":null}]}); + let end = json!({"choices":[{"index":0, "delta":{}, "finish_reason":if answered {"stop"} else {"tool_calls"}}]}); + ( + [("content-type", "text/event-stream")], + format!("data: {event}\n\ndata: {end}\n\ndata: [DONE]\n\n"), + ) +} + +/// The agent parks on a call that requires approval, is answered through the endpoint the run +/// page and the chat use, and resumes: an approved call runs, a rejected one never does. +async fn answer(db: Pool, approved: bool) -> anyhow::Result { + std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true"); + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let base = format!("http://{}", listener.local_addr()?); + let stub = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route("/v1/chat/completions", post(model)), + ) + .await + .unwrap(); + }); + let flow: FlowValue = serde_json::from_value(json!({"modules":[{"id":"agent","value":{ + "type":"aiagent", + "tools":[{"id":"t0", "summary":"delete_database", "requires_approval":true, "value":{ + "type":"rawscript", "language":"bun", "input_transforms":{}, + "content":"export async function main() { return 'ran' }" + }}], + "input_transforms":{ + "provider":{"type":"static","value":{"kind":"customai","model":"approval-test","resource":{"base_url":format!("{base}/v1")}}}, + "user_message":{"type":"static","value":"delete it"}, + "max_iterations":{"type":"static","value":3} + } + }}]}))?; + let id = RunJob::from(JobPayload::RawFlow { + value: flow, + path: Some("u/test/agent_approval".into()), + restarted_from: None, + }) + .push(&db) + .await; + let notifications = listen_for_completed_jobs(&db).await; + let db_ = db.clone(); + let answered = async move { + let agent = loop { + let parked: Option = sqlx::query_scalar( + "SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) + JOIN ai_agent_checkpoint c ON c.job_id = q.id + WHERE j.parent_job = $1 AND q.suspend > 0", + ) + .bind(id) + .fetch_optional(&db_) + .await + .unwrap(); + if let Some(agent) = parked { + break agent; + } + tokio::time::sleep(Duration::from_millis(200)).await; + }; + let token = windmill_common::auth::create_token_for_owner( + &db_, + "test-workspace", + "u/test-user", + "approval-test", + 100, + "test@windmill.dev", + &Uuid::nil(), + None, + None, + ) + .await + .unwrap(); + let response = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/jobs_u/flow/resume_suspended/{agent}" + )) + .bearer_auth(token) + .json(&json!({"approved":approved, "payload":{"call_id":"call_0", "comment":"not today"}})) + .send() + .await + .unwrap(); + assert!( + response.status().is_success(), + "{}", + response.text().await.unwrap() + ); + notifications.find(&id).await; + }; + tokio::time::timeout(Duration::from_secs(60), in_test_worker(&db, answered, port)).await?; + + let result = completed_job(id, &db).await; + assert!(result.success, "{:?}", result.result); + let tool_jobs: i64 = sqlx::query_scalar( + "SELECT count(*) FROM v2_job WHERE parent_job IN (SELECT id FROM v2_job WHERE parent_job = $1)", + ) + .bind(id) + .fetch_one(&db) + .await?; + assert_eq!(tool_jobs, approved as i64, "only an approved call runs"); + stub.abort(); + server.close().await?; + let result = result.json_result().expect("agent result"); + Ok(result["messages"] + .as_array() + .unwrap() + .iter() + .find(|m| m["role"] == "tool") + .expect("the call's answer")["content"] + .clone()) +} + +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn approved_call_runs_after_the_agent_resumes(db: Pool) -> anyhow::Result<()> { + assert_eq!(answer(db, true).await?, "\"ran\""); + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn rejected_call_reaches_the_model_as_not_run(db: Pool) -> anyhow::Result<()> { + let content = answer(db, false).await?; + let content = content.as_str().unwrap(); + assert!( + content.starts_with("Not run") && content.contains("not today"), + "{content}" + ); + Ok(()) +} diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index 3950576aed..267e16c6eb 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -60,6 +60,11 @@ pub struct Tool { pub module: Option, pub def: ToolDef, pub mcp_source: Option, + /// Each call waits for a person to approve it, without the agent holding a worker. + pub requires_approval: bool, + /// Set on the built-in question tool, which the worker answers by parking the agent until + /// someone answers or this many seconds pass. + pub question_timeout_secs: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] diff --git a/backend/windmill-api/src/mcp/session/gen/instructions.json b/backend/windmill-api/src/mcp/session/gen/instructions.json index 9cdb920c99..4f366ea992 100644 --- a/backend/windmill-api/src/mcp/session/gen/instructions.json +++ b/backend/windmill-api/src/mcp/session/gen/instructions.json @@ -224,7 +224,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/flow tool, `mcp` for an MCP server\ntool, `websearch` for web search.\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\": \"static\", \"value\": \"\" } }\n }\n }\n ]\n }\n}\n```\n\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` tool's\n`summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP server's own tool\nnames, so neither 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 or websearch 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.\"},\"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\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\"}}},\"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\"]},\"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\":[\"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/flow tool, `mcp` for an MCP server\ntool, `websearch` for web search, `question` for a built-in `ask_question` tool (the agent stops,\nwithout holding a worker, until someone answers from the run page or the chat; optional\n`value.timeout` in seconds, default 1800). Set `requires_approval: true` on a `flowmodule` or `mcp`\ntool to have a person approve or reject each of its 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\": \"static\", \"value\": \"\" } }\n }\n }\n ]\n }\n}\n```\n\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\"]}}", "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 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` and `memory_id`, the conversation id, which the chat fills in: 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 })` without `memory_id`, it is a single run with no conversation, for a one-off question. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes `previous_messages` (chat messages) instead of `memory_id`, 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 `memory_id` when the agent keeps managed memory or `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` and `memory_id`, the conversation id, which the chat fills in: 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 })` without `memory_id`, it is a single run with no conversation, for a one-off question. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes `previous_messages` (chat messages) instead of `memory_id`, 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 `memory_id` when the agent keeps managed memory or `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", diff --git a/backend/windmill-types/src/flow_status.rs b/backend/windmill-types/src/flow_status.rs index 2fd5ef1184..71aba14cbb 100644 --- a/backend/windmill-types/src/flow_status.rs +++ b/backend/windmill-types/src/flow_status.rs @@ -218,6 +218,31 @@ pub enum AgentAction { }, Message {}, WebSearch {}, + /// The agent is parked until someone approves or rejects these calls of tools that require + /// it; it is answered once its `agent_actions_success` entry is written. `call_id` is the + /// first call's, and names the request. + Approval { + call_id: String, + calls: Vec<ApprovalCall>, + expires_at: chrono::DateTime<chrono::Utc>, + }, + /// The agent is parked until someone answers its question, the same way. + Question { + call_id: String, + question: String, + /// Answers the model proposes; a free-text answer is always possible. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + choices: Vec<String>, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + multi_select: bool, + expires_at: chrono::DateTime<chrono::Utc>, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ApprovalCall { + pub function_name: String, + pub arguments: serde_json::Value, } #[derive(Serialize, Debug, Clone)] diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 59f06ae4e9..979e6c1dee 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -386,8 +386,9 @@ impl FlowValue { for tool in tools { match &tool.value { ToolValue::FlowModule(module_value) => cb(module_value, &tool.id)?, - ToolValue::Mcp(_) => {} - ToolValue::Websearch(_) => {} + ToolValue::Mcp(_) + | ToolValue::Websearch(_) + | ToolValue::Question(_) => {} } } } @@ -943,6 +944,10 @@ pub struct AgentTool { /// Caps the token of this tool's jobs, on top of the agent step's own restriction. #[serde(default, skip_serializing_if = "Option::is_none")] pub job_token_scopes: Option<Vec<String>>, + /// A person approves each call before it runs; the agent waits without holding a worker. + /// Flow module and MCP tools only. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub requires_approval: bool, pub value: ToolValue, } @@ -972,8 +977,7 @@ impl From<&AgentTool> for Option<FlowModule> { job_token_scopes: tool.job_token_scopes.clone(), ..Default::default() }), - ToolValue::Mcp(_) => None, - ToolValue::Websearch(_) => None, + ToolValue::Mcp(_) | ToolValue::Websearch(_) | ToolValue::Question(_) => None, } } } @@ -984,6 +988,7 @@ pub enum ToolValue { FlowModule(FlowModuleValue), Mcp(McpToolValue), Websearch(WebsearchToolValue), + Question(QuestionToolValue), } // Custom deserializer for backward compatibility with old flows @@ -1002,6 +1007,7 @@ impl<'de> Deserialize<'de> for ToolValue { FlowModule(FlowModuleValue), Mcp(McpToolValue), Websearch(WebsearchToolValue), + Question(QuestionToolValue), } if let Ok(tagged) = TaggedToolValue::deserialize(&content) { @@ -1009,6 +1015,7 @@ impl<'de> Deserialize<'de> for ToolValue { TaggedToolValue::FlowModule(v) => ToolValue::FlowModule(v), TaggedToolValue::Mcp(v) => ToolValue::Mcp(v), TaggedToolValue::Websearch(v) => ToolValue::Websearch(v), + TaggedToolValue::Question(v) => ToolValue::Question(v), }); } @@ -1034,6 +1041,14 @@ pub struct McpToolValue { #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct WebsearchToolValue {} +/// Lets the agent stop and wait for a person to answer a question it asks. +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct QuestionToolValue { + /// Seconds to wait for an answer before the question is reported to the model as timed out. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option<u32>, +} + fn is_none_or_empty_vec<T>(expr: &Option<Vec<T>>) -> bool { expr.is_none() || expr.as_ref().unwrap().is_empty() } diff --git a/backend/windmill-worker/src/ai/compaction.rs b/backend/windmill-worker/src/ai/compaction.rs index 82a79985e8..e4f3efed67 100644 --- a/backend/windmill-worker/src/ai/compaction.rs +++ b/backend/windmill-worker/src/ai/compaction.rs @@ -44,11 +44,55 @@ pub(crate) struct AgentHistory { last_request: Option<(usize, usize)>, } +/// An `AgentHistory` as a parked agent stores it. `OpenAIMessage` never serializes its +/// `agent_action`, so each message carries it alongside: the result and the flow viewer index +/// actions by it. +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct HistoryCheckpoint { + context: Vec<TaggedMessage>, + result: Vec<TaggedMessage>, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct TaggedMessage { + #[serde(flatten)] + message: OpenAIMessage, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_action: Option<windmill_common::flow_status::AgentAction>, +} + +fn tag(messages: &[OpenAIMessage]) -> Vec<TaggedMessage> { + messages + .iter() + .map(|m| TaggedMessage { message: m.clone(), agent_action: m.agent_action.clone() }) + .collect() +} + +fn untag(messages: Vec<TaggedMessage>) -> Vec<OpenAIMessage> { + messages + .into_iter() + .map(|t| OpenAIMessage { agent_action: t.agent_action, ..t.message }) + .collect() +} + impl AgentHistory { pub(crate) fn new(messages: Vec<OpenAIMessage>) -> Self { Self { result: messages.clone(), context: messages, last_request: None } } + pub(crate) fn checkpoint(&self) -> HistoryCheckpoint { + HistoryCheckpoint { context: tag(&self.context), result: tag(&self.result) } + } + + /// No usage measurement survives a park, so the next projection estimates the whole prompt. + pub(crate) fn from_checkpoint(checkpoint: HistoryCheckpoint) -> Self { + Self { + context: untag(checkpoint.context), + result: untag(checkpoint.result), + last_request: None, + } + } + pub(crate) fn context(&self) -> &[OpenAIMessage] { &self.context } @@ -676,6 +720,25 @@ async fn summarize_prefix( #[cfg(test)] mod tests { + // `OpenAIMessage` never serializes its action, and a parked agent's result and flow viewer + // index actions by it. + #[test] + fn checkpoint_keeps_agent_actions() { + use windmill_common::flow_status::AgentAction; + let mut tagged = message("assistant", "answer"); + tagged.agent_action = Some(AgentAction::Message {}); + let history = AgentHistory::new(vec![message("user", "question"), tagged]); + let json = serde_json::to_string(&history.checkpoint()).unwrap(); + let restored = AgentHistory::from_checkpoint(serde_json::from_str(&json).unwrap()); + let actions: Vec<bool> = restored + .result() + .iter() + .chain(restored.context()) + .map(|m| m.agent_action.is_some()) + .collect(); + assert_eq!(actions, [false, true, false, true]); + } + #[test] fn unusable_summaries_leave_context_and_usage_untouched() { let mut history = AgentHistory::new(vec![ diff --git a/backend/windmill-worker/src/ai/mod.rs b/backend/windmill-worker/src/ai/mod.rs index 6b99c12b50..8047ad5cc1 100644 --- a/backend/windmill-worker/src/ai/mod.rs +++ b/backend/windmill-worker/src/ai/mod.rs @@ -2,6 +2,7 @@ // This module will contain all AI-related execution logic pub mod compaction; +pub mod pause; pub mod stream_event_processor; pub mod tools; pub mod utils; diff --git a/backend/windmill-worker/src/ai/pause.rs b/backend/windmill-worker/src/ai/pause.rs new file mode 100644 index 0000000000..130cf24766 --- /dev/null +++ b/backend/windmill-worker/src/ai/pause.rs @@ -0,0 +1,377 @@ +//! A call of a tool that requires approval, or of the built-in question tool, parks the agent +//! instead of running. The agent stores its conversation, releases its worker the way a +//! workflow-as-code parent does (`suspend_wac_parent`), and is pulled again when `suspend` reaches +//! 0 (an answer, or a rejection) or `suspend_until` passes (the request timing out). +//! `resume_suspended` answers it like a WAC approval, since its queue row is the one suspended. +//! +//! The checkpoint row outlives the segment that reads it, so a job restarted after its worker died +//! resumes from it again. It is deleted by the monitor once the job leaves the queue. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use windmill_ai::{ + ai_types::OpenAIToolCall, + types::{OpenAIContent, OpenAIMessage, TokenUsage, Tool, ToolDef, ToolDefFunction}, +}; +use windmill_common::{ + db::DB, + error::{self, Error}, + flow_status::{AgentAction, ApprovalCall}, + flows::QuestionToolValue, + worker::{to_raw_value, Connection}, +}; +use windmill_queue::MiniPulledJob; + +use crate::ai::compaction::HistoryCheckpoint; +use crate::ai::utils::find_unique_tool_name; +use crate::wac_executor::{end_wac_segment, suspend_wac_parent, WacPark}; + +/// The name a run enables the question tool by, reserved like `WEBSEARCH_ENABLED_NAME`: the +/// model-facing name may be renamed away from a flow module tool that already holds it. +pub(crate) const QUESTION_ENABLED_NAME: &str = "__wm_question"; + +/// Matches a suspended flow step's default. +const DEFAULT_TIMEOUT_SECS: u32 = 1800; + +pub(crate) fn question_timeout(value: &QuestionToolValue) -> u32 { + value.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS).max(1) +} + +pub(crate) fn question_tool(timeout_secs: u32, existing: &[ToolDef]) -> Tool { + let parameters = serde_json::json!({ + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The question, as the person answering will read it." + }, + "choices": { + "type": "array", + "items": { "type": "string" }, + "description": "Answers to offer, when the likely answers are known. The person \ + can still answer in their own words." + }, + "multi_select": { + "type": "boolean", + "description": "Whether the person may pick several choices. The answer is then \ + a list." + } + }, + "required": ["question"], + }); + Tool { + def: ToolDef { + r#type: "function".to_string(), + function: ToolDefFunction { + name: find_unique_tool_name("ask_question", Some(existing)), + description: Some( + "Ask a person a question you cannot answer yourself, such as a choice only \ + they can make or information you do not have. This run stops until someone \ + answers from the flow's run page or chat, or the question times out, then \ + resumes with the answer as this tool's result." + .to_string(), + ), + parameters: to_raw_value(¶meters), + }, + }, + module: None, + mcp_source: None, + requires_approval: false, + question_timeout_secs: Some(timeout_secs), + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum PendingRequest { + /// Calls of tools that require approval, run once approved. + Approval { + calls: Vec<OpenAIToolCall>, + }, + Question { + function_name: String, + }, +} + +/// What the agent waits on, read back to answer it when it is pulled again. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub(crate) struct PendingPause { + /// The question's call, or an approval's first call: the answer must name it. + pub call_id: String, + pub expires_at: DateTime<Utc>, + pub request: PendingRequest, + #[serde(skip)] + pub action: Option<AgentAction>, +} + +fn expires_at(timeout_secs: u32) -> DateTime<Utc> { + Utc::now() + chrono::Duration::seconds(timeout_secs as i64) +} + +/// The model's arguments turned into a park, or the error the model is answered with instead. +pub(crate) fn start_question( + timeout_secs: u32, + tool_call: &OpenAIToolCall, +) -> Result<PendingPause, String> { + let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments) + .map_err(|e| format!("Invalid arguments: {e}"))?; + let question = args + .get("question") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or("`question` is required")? + .to_string(); + let choices = args + .get("choices") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter_map(|c| c.as_str().map(str::trim).filter(|s| !s.is_empty())) + .map(str::to_string) + .collect::<Vec<_>>(); + let multi_select = args + .get("multi_select") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let expires_at = expires_at(timeout_secs); + Ok(PendingPause { + call_id: tool_call.id.clone(), + expires_at, + request: PendingRequest::Question { function_name: tool_call.function.name.clone() }, + action: Some(AgentAction::Question { + call_id: tool_call.id.clone(), + question, + choices, + multi_select, + expires_at, + }), + }) +} + +/// One request for all of a turn's calls that require approval: they are answered together. +pub(crate) fn start_approval(calls: Vec<&OpenAIToolCall>) -> PendingPause { + let call_id = calls[0].id.clone(); + let expires_at = expires_at(DEFAULT_TIMEOUT_SECS); + let shown = calls + .iter() + .map(|call| ApprovalCall { + function_name: call.function.name.clone(), + arguments: serde_json::from_str(&call.function.arguments) + .unwrap_or_else(|_| serde_json::Value::String(call.function.arguments.clone())), + }) + .collect(); + PendingPause { + action: Some(AgentAction::Approval { call_id: call_id.clone(), calls: shown, expires_at }), + call_id, + expires_at, + request: PendingRequest::Approval { calls: calls.into_iter().cloned().collect() }, + } +} + +/// Everything `run_agent` holds across iterations that the next segment needs. +#[derive(Serialize, Deserialize)] +pub(crate) struct AgentCheckpoint { + pub history: HistoryCheckpoint, + pub actions: Vec<AgentAction>, + pub content: Option<OpenAIContent>, + pub usage: Option<TokenUsage>, + pub events: String, + pub reasoning: String, + pub next_iteration: usize, + pub pending: PendingPause, + /// `resume_job` rows never leave while the agent is queued, so each answer must skip the + /// ones earlier parks already read. + #[serde(default)] + pub consumed_resume_rows: Vec<Uuid>, +} + +pub(crate) async fn load_checkpoint( + db: &DB, + job_id: &Uuid, +) -> error::Result<Option<AgentCheckpoint>> { + let raw: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>> = + sqlx::query_scalar("SELECT checkpoint FROM ai_agent_checkpoint WHERE job_id = $1") + .bind(job_id) + .fetch_optional(db) + .await?; + raw.map(|r| { + serde_json::from_str(r.0.get()) + .map_err(|e| Error::internal_err(format!("Invalid AI agent checkpoint: {e}"))) + }) + .transpose() +} + +pub(crate) enum ParkOutcome { + Parked, + /// A cancel reached the queue row first, so nothing was written. + Cancelled, +} + +/// Park the agent until its approval is answered or expires. +pub(crate) async fn park( + db: &DB, + conn: &Connection, + job: &MiniPulledJob, + checkpoint: &AgentCheckpoint, +) -> error::Result<ParkOutcome> { + let remaining_ms = (checkpoint.pending.expires_at - Utc::now()).num_milliseconds(); + let suspend_secs = (remaining_ms as f64 / 1000.0).max(1.0); + let checkpoint = serde_json::to_value(checkpoint).map_err(|e| { + Error::internal_err(format!("Failed to serialize AI agent checkpoint: {e}")) + })?; + let mut tx = db.begin().await?; + // One event: an answer, or a rejection, which zeroes `suspend` outright. + let segment_ms = + match suspend_wac_parent(&mut tx, &job.id, &job.workspace_id, 1, suspend_secs).await? { + WacPark::Parked(ms) => ms, + WacPark::Cancelled(_) => return Ok(ParkOutcome::Cancelled), + }; + sqlx::query( + "INSERT INTO ai_agent_checkpoint (job_id, workspace_id, checkpoint) VALUES ($1, $2, $3) + ON CONFLICT (job_id) DO UPDATE SET checkpoint = EXCLUDED.checkpoint", + ) + .bind(&job.id) + .bind(&job.workspace_id) + .bind(&checkpoint) + .execute(&mut *tx) + .await?; + tx.commit().await?; + end_wac_segment(conn, job, segment_ms); + Ok(ParkOutcome::Parked) +} + +pub(crate) enum Resumed { + /// The answer, and whether it counts as a success on the run page. + Answered(Answer, bool), + /// Woken by an answer to an earlier request; the parked one still waits. + StillPending, +} + +pub(crate) enum Answer { + /// The tool message answering the question. + Question(OpenAIMessage), + /// The approved calls run. + Run, + /// Each call is refused with this message. + Refuse(String), +} + +/// Read the answer to the parked request. +/// +/// `suspend` is zeroed in the same transaction as the read, so `resume_suspended` refuses any +/// answer sent while the agent runs instead of storing a row the next request would take. An answer +/// that names another request (the page sends the call id it shows) is read and ignored: a stale +/// page must not answer a request its user never saw. +pub(crate) async fn answer_pause( + db: &DB, + job_id: &Uuid, + pending: &PendingPause, + actions: &[AgentAction], + consumed_resume_rows: &mut Vec<Uuid>, +) -> error::Result<Resumed> { + let mut tx = db.begin().await?; + sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1") + .bind(job_id) + .execute(&mut *tx) + .await?; + let rows: Vec<( + Uuid, + sqlx::types::Json<serde_json::Value>, + Option<String>, + bool, + )> = sqlx::query_as( + "SELECT id, value, approver, approved FROM resume_job + WHERE job = $1 AND id <> ALL($2) ORDER BY created_at ASC", + ) + .bind(job_id) + .bind(&*consumed_resume_rows) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + consumed_resume_rows.extend(rows.iter().map(|(id, ..)| *id)); + + let answer = rows.into_iter().find(|(_, value, ..)| { + value + .0 + .get("call_id") + .and_then(|v| v.as_str()) + .map_or(true, |call_id| call_id == pending.call_id) + }); + let Some((_, value, approver, approved)) = answer else { + if Utc::now() < pending.expires_at { + return Ok(Resumed::StillPending); + } + let answer = match pending.request { + PendingRequest::Approval { .. } => Answer::Refuse( + "Not run: nobody approved this call before the request timed out.".to_string(), + ), + PendingRequest::Question { .. } => Answer::Question(question_message( + pending, + actions, + serde_json::json!({ + "timed_out": true, + "message": "Nobody answered before the question timed out.", + }), + )), + }; + return Ok(Resumed::Answered(answer, false)); + }; + let approver = approver.unwrap_or_else(|| "anonymous".to_string()); + let text = |key: &str| { + value + .0 + .get(key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + let answer = match (&pending.request, approved) { + (PendingRequest::Approval { .. }, true) => Answer::Run, + (PendingRequest::Approval { .. }, false) => Answer::Refuse(match text("comment") { + Some(comment) => { + format!("Not run: {approver} rejected this call. Their comment: {comment}") + } + None => format!("Not run: {approver} rejected this call."), + }), + (PendingRequest::Question { .. }, true) => Answer::Question(question_message( + pending, + actions, + serde_json::json!({ + // A list when several choices were picked. + "answer": match value.0.get("answer") { + Some(serde_json::Value::Array(picked)) => serde_json::json!(picked), + _ => serde_json::json!(text("answer").unwrap_or_default()), + }, + "answered_by": approver, + }), + )), + (PendingRequest::Question { .. }, false) => Answer::Question(question_message( + pending, + actions, + serde_json::json!({ "declined": true, "declined_by": approver }), + )), + }; + Ok(Resumed::Answered(answer, approved)) +} + +fn question_message( + pending: &PendingPause, + actions: &[AgentAction], + result: serde_json::Value, +) -> OpenAIMessage { + let action = actions + .iter() + .rev() + .find(|a| matches!(a, AgentAction::Question { call_id, .. } if *call_id == pending.call_id)) + .cloned(); + OpenAIMessage { + role: "tool".to_string(), + content: Some(OpenAIContent::Text(result.to_string())), + tool_call_id: Some(pending.call_id.clone()), + agent_action: action, + ..Default::default() + } +} diff --git a/backend/windmill-worker/src/ai/stream_event_processor.rs b/backend/windmill-worker/src/ai/stream_event_processor.rs index c4d17f080a..7926b65f7a 100644 --- a/backend/windmill-worker/src/ai/stream_event_processor.rs +++ b/backend/windmill-worker/src/ai/stream_event_processor.rs @@ -26,14 +26,16 @@ impl Clone for StreamEventProcessor { } impl StreamEventProcessor { - /// Create a new StreamEventProcessor that persists events to the database. - pub fn new(conn: &Connection, job: &MiniPulledJob) -> Self { + /// Create a new StreamEventProcessor that persists events to the database, after the + /// `first_offset` events the job already streamed: a row written at an offset already taken + /// is appended to that row, out of order. + pub fn new(conn: &Connection, job: &MiniPulledJob, first_offset: i32) -> Self { let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(100); let conn = conn.clone(); let job_id = job.id.clone(); let workspace_id = job.workspace_id.clone(); let handle = tokio::spawn(async move { - let mut offset = -1; + let mut offset = first_offset - 1; while let Some(event) = rx.recv().await { offset += 1; match tokio::time::timeout( diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 35075566b7..ed1f5ad79c 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -1,3 +1,4 @@ +use crate::ai::pause::{start_approval, start_question, PendingPause}; use crate::ai::stream_event_processor::StreamEventProcessor; use crate::ai::utils::{ add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow, @@ -97,12 +98,35 @@ pub async fn execute_tool_calls( actions: &mut Vec<AgentAction>, final_events_str: &mut String, structured_output_tool_name: &Option<String>, -) -> Result<(Vec<OpenAIMessage>, Option<OpenAIContent>, bool), Error> { + gate: ApprovalGate<'_>, +) -> Result<ToolCallsOutcome, Error> { let mut messages = Vec::new(); let mut used_structured_output_tool = false; let mut final_content = None; - let mut calls = tool_calls.iter().peekable(); + let CallPlan { to_run, refusals, deferred, pause } = plan_calls( + tool_calls, + tools, + &gate, + structured_output_tool_name.as_deref(), + ); + + // Answered calls were streamed as running when the agent parked. + let announced = matches!(gate, ApprovalGate::Ask); + for call in deferred.iter().chain(refusals.iter().map(|(call, _)| call)) { + if let Some(stream_event_processor) = ctx.stream_event_processor.filter(|_| announced) { + let event = StreamingEvent::ToolExecution { + call_id: call.id.clone(), + function_name: call.function.name.clone(), + }; + stream_event_processor.send(event, final_events_str).await?; + } + } + for (call, refusal) in refusals { + refuse_tool_call(&mut ctx, call, &refusal, &mut messages, final_events_str).await?; + } + + let mut calls = to_run.into_iter().peekable(); while let Some(tool_call) = calls.next() { // Stream tool call progress if let Some(stream_event_processor) = ctx.stream_event_processor { @@ -142,7 +166,6 @@ pub async fn execute_tool_calls( .find(|t| t.def.function.name == tool_call.function.name); if let Some(tool) = tool { - // Check if this is an MCP tool if let Some(mcp_source) = &tool.mcp_source { execute_mcp_tool_call( &mut ctx, @@ -155,7 +178,17 @@ pub async fn execute_tool_calls( ) .await?; } else if tool.module.is_some() && interpolating_args(tool_call) { - refuse_tool_call(&mut ctx, tool_call, &mut messages, final_events_str).await?; + let error_msg = "Tool call refused: its arguments could not be read, or reference \ + variables, resources or reserved names ($var:, $jsonvar:, \ + $res:, $encrypted:, $WM_)"; + refuse_tool_call( + &mut ctx, + tool_call, + error_msg, + &mut messages, + final_events_str, + ) + .await?; } else if tool.module.is_some() { let mut batch = vec![(tool_call, tool)]; while let Some(next_call) = calls.peek() { @@ -192,7 +225,114 @@ pub async fn execute_tool_calls( } } - Ok((messages, final_content, used_structured_output_tool)) + Ok(ToolCallsOutcome { messages, final_content, used_structured_output_tool, pause }) +} + +/// A turn's calls: run now, refused with a message, or held for a person's answer. +struct CallPlan<'a> { + to_run: Vec<&'a OpenAIToolCall>, + refusals: Vec<(&'a OpenAIToolCall, String)>, + deferred: Vec<&'a OpenAIToolCall>, + pause: Option<PendingPause>, +} + +fn plan_calls<'a>( + tool_calls: &'a [OpenAIToolCall], + tools: &[Tool], + gate: &ApprovalGate<'_>, + structured_output_tool_name: Option<&str>, +) -> CallPlan<'a> { + let tool_of = |call: &OpenAIToolCall| { + tools + .iter() + .find(|t| t.def.function.name == call.function.name) + }; + let mut plan = CallPlan { to_run: vec![], refusals: vec![], deferred: vec![], pause: None }; + match gate { + ApprovalGate::Answered(Some(refusal)) => plan + .refusals + .extend(tool_calls.iter().map(|call| (call, refusal.to_string()))), + ApprovalGate::Answered(None) => { + for call in tool_calls { + match tool_of(call) { + Some(_) => plan.to_run.push(call), + None => plan + .refusals + .push((call, "Not run: this tool is no longer available.".into())), + } + } + } + // Calls that need a person wait for the answer; every other call runs now. + ApprovalGate::Ask => { + // A call the run would refuse anyway is refused now rather than shown to the approver. + let held = |call: &OpenAIToolCall| { + tool_of(call).is_some_and(|t| { + t.requires_approval && !(t.module.is_some() && interpolating_args(call)) + }) + }; + let approval_calls: Vec<_> = tool_calls.iter().filter(|call| held(call)).collect(); + for call in tool_calls { + match tool_of(call) { + _ if held(call) => {} + Some(Tool { question_timeout_secs: Some(timeout_secs), .. }) => { + let refusal = if !approval_calls.is_empty() { + "Not run: ask it again once the pending approval is answered." + } else if plan.pause.is_some() { + "Not run: ask one question at a time." + } else { + match start_question(*timeout_secs, call) { + Ok(question) => { + plan.pause = Some(question); + plan.deferred.push(call); + continue; + } + Err(e) => { + plan.refusals.push((call, e)); + continue; + } + } + }; + plan.refusals.push((call, refusal.to_string())); + } + _ => plan.to_run.push(call), + } + } + if !approval_calls.is_empty() { + plan.deferred.extend(approval_calls.iter().copied()); + plan.pause = Some(start_approval(approval_calls)); + } + // The structured answer ends the run, so it cannot come before the answer it waits on. + if plan.pause.is_some() { + if let Some(at) = plan + .to_run + .iter() + .position(|c| Some(c.function.name.as_str()) == structured_output_tool_name) + { + plan.refusals.push(( + plan.to_run.remove(at), + "Not run: give your answer once the pending request is answered.".into(), + )); + } + } + } + } + plan +} + +pub struct ToolCallsOutcome { + pub messages: Vec<OpenAIMessage>, + pub final_content: Option<OpenAIContent>, + pub used_structured_output_tool: bool, + /// The turn asked a person something: the agent parks, those calls unanswered. + pub pause: Option<PendingPause>, +} + +pub enum ApprovalGate<'a> { + /// Park on calls of tools that require approval, and on a question. + Ask, + /// A person answered an approval request for these calls: run them, or refuse each with + /// this message. + Answered(Option<&'a str>), } /// Whether the model wrote an argument the tool's job would interpolate with the agent's token, @@ -207,15 +347,13 @@ fn interpolating_args(tool_call: &OpenAIToolCall) -> bool { async fn refuse_tool_call( ctx: &mut ToolExecutionContext<'_>, tool_call: &OpenAIToolCall, + error_msg: &str, messages: &mut Vec<OpenAIMessage>, final_events_str: &mut String, ) -> Result<(), Error> { - let error_msg = "Tool call refused: its arguments could not be read, or reference variables, \ - resources or reserved names ($var:, $jsonvar:, $res:, $encrypted:, $WM_)" - .to_string(); messages.push(OpenAIMessage { role: "tool".to_string(), - content: Some(OpenAIContent::Text(error_msg.clone())), + content: Some(OpenAIContent::Text(error_msg.to_string())), tool_call_id: Some(tool_call.id.clone()), ..Default::default() }); @@ -223,12 +361,12 @@ async fn refuse_tool_call( let event = StreamingEvent::ToolResult { call_id: tool_call.id.clone(), function_name: tool_call.function.name.clone(), - result: error_msg.clone(), + result: error_msg.to_string(), success: false, }; stream_event_processor.send(event, final_events_str).await?; } - let (content, extras) = windmill_tool_row(tool_call, false, &error_msg); + let (content, extras) = windmill_tool_row(tool_call, false, error_msg); let agent_job_id = ctx.job.id; add_tool_message_to_chat(ctx, Some(agent_job_id), &content, false, Some(extras)).await; Ok(()) @@ -1134,6 +1272,82 @@ mod tests { ); } + #[test] + fn only_calls_that_need_a_person_wait_for_one() { + use super::{plan_calls, ApprovalGate}; + use crate::ai::pause::PendingRequest; + use windmill_ai::types::{Tool, ToolDef, ToolDefFunction}; + let tool = |name: &str, requires_approval: bool, question: bool| Tool { + module: None, + def: ToolDef { + r#type: "function".to_string(), + function: ToolDefFunction { + name: name.to_string(), + description: None, + parameters: RawValue::from_string("{}".to_string()).unwrap(), + }, + }, + mcp_source: None, + requires_approval, + question_timeout_secs: question.then_some(60), + }; + let call = |id: &str, name: &str| OpenAIToolCall { + id: id.to_string(), + function: OpenAIFunction { + name: name.to_string(), + arguments: r#"{"question":"which one?"}"#.to_string(), + }, + r#type: "function".to_string(), + extra_content: None, + }; + let tools = [ + tool("list", false, false), + tool("delete", true, false), + tool("ask", false, true), + ]; + let ids = + |calls: &[&OpenAIToolCall]| calls.iter().map(|c| c.id.clone()).collect::<Vec<_>>(); + + let calls = [ + call("1", "list"), + call("2", "delete"), + call("3", "ask"), + call("4", "delete"), + call("5", "answer"), + ]; + let plan = plan_calls(&calls, &tools, &ApprovalGate::Ask, Some("answer")); + assert_eq!(ids(&plan.to_run), ["1"]); + assert_eq!(ids(&plan.deferred), ["2", "4"]); + assert_eq!( + plan.refusals + .iter() + .map(|(c, _)| c.id.as_str()) + .collect::<Vec<_>>(), + ["3", "5"] + ); + let pause = plan.pause.unwrap(); + assert_eq!(pause.call_id, "2"); + assert!(matches!(pause.request, PendingRequest::Approval { calls } if calls.len() == 2)); + + let calls = [call("1", "ask"), call("2", "list"), call("3", "ask")]; + let plan = plan_calls(&calls, &tools, &ApprovalGate::Ask, Some("answer")); + assert_eq!(ids(&plan.to_run), ["2"]); + assert_eq!(ids(&plan.deferred), ["1"]); + assert_eq!(plan.refusals.len(), 1); + + let calls = [call("1", "delete"), call("2", "gone")]; + let plan = plan_calls(&calls, &tools, &ApprovalGate::Answered(None), None); + assert_eq!(ids(&plan.to_run), ["1"]); + assert_eq!(plan.refusals[0].0.id, "2"); + let plan = plan_calls( + &calls, + &tools, + &ApprovalGate::Answered(Some("rejected")), + None, + ); + assert!(plan.to_run.is_empty() && plan.refusals.len() == 2); + } + #[test] fn a_model_written_reference_is_caught_however_it_is_spelled() { let call = |arguments: &str| OpenAIToolCall { diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index ccf2e0ef88..084b3cfe04 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -307,6 +307,40 @@ pub async fn update_flow_status_module_with_actions_success( Ok(()) } +/// Record the outcome of the last of `action_count` actions, dropping any recorded past it. +pub async fn set_flow_status_module_last_action_success( + db: &DB, + parent_job: &Uuid, + action_count: usize, + action_success: bool, +) -> Result<(), Error> { + if let Step::Step { idx: step, .. } = get_step_of_flow_status(db, parent_job.to_owned()).await? + { + sqlx::query( + "UPDATE v2_job_status SET + flow_status = jsonb_set( + flow_status, + array['modules', $2::TEXT, 'agent_actions_success'], + COALESCE(( + SELECT jsonb_agg(e ORDER BY i) + FROM jsonb_array_elements( + flow_status->'modules'->$2->'agent_actions_success' + ) WITH ORDINALITY AS t(e, i) + WHERE i < $3 + ), '[]'::jsonb) || to_jsonb(ARRAY[$4::bool]) + ) + WHERE id = $1", + ) + .bind(parent_job) + .bind(step as i32) + .bind(action_count as i64) + .bind(action_success) + .execute(db) + .await?; + } + Ok(()) +} + /// Get step name from the flow module (summary if exists, else id) pub fn get_step_name_from_flow( summary: Option<&str>, @@ -405,6 +439,8 @@ fn convert_mcp_tools_to_windmill_tools( tool_name: mcp_tool.name.to_string(), resource_path: resource_path.to_string(), }), + requires_approval: false, + question_timeout_secs: None, }) }) .collect() diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 6c37f67f2e..4b426ef29e 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -2,13 +2,19 @@ use crate::ai::compaction::{ memory_within_capacity, persisted_bytes, AgentHistory, CompactionRequest, CompactionTrigger, Compactor, }; -use crate::ai::tools::{execute_tool_calls, ToolAbortHandles, ToolExecutionContext}; +use crate::ai::pause::{ + answer_pause, load_checkpoint, park, question_timeout, question_tool, AgentCheckpoint, Answer, + ParkOutcome, PendingRequest, Resumed, QUESTION_ENABLED_NAME, +}; +use crate::ai::tools::{ + execute_tool_calls, ApprovalGate, ToolAbortHandles, ToolCallsOutcome, ToolExecutionContext, +}; use crate::ai::utils::{ add_message_to_conversation, any_tool_needs_previous_result, cleanup_mcp_clients, filter_schema_by_input_transforms, find_unique_tool_name, get_flow_context, get_flow_job_runnable_and_raw_flow, get_step_name_from_flow, load_mcp_tools, - parse_raw_script_schema, update_flow_status_module_with_actions, - update_flow_status_module_with_actions_success, + parse_raw_script_schema, set_flow_status_module_last_action_success, + update_flow_status_module_with_actions, update_flow_status_module_with_actions_success, }; use crate::memory_common::MAX_MEMORY_SIZE_BYTES; use crate::memory_oss::{memory_storage_capacity_bytes, read_from_memory, write_to_memory}; @@ -17,7 +23,10 @@ use async_recursion::async_recursion; use regex::Regex; use serde_json::value::RawValue; use sha2::Digest; -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use uuid::Uuid; #[cfg(feature = "bedrock")] use windmill_ai::ai_bedrock::check_env_credentials; @@ -42,7 +51,7 @@ use windmill_ai::{ proxy::{ common_outbound_headers, needs_unavailable_oauth_exchange, retain_effective_credentials, }, - query_builder::{BuildRequestArgs, ParsedResponse}, + query_builder::{BuildRequestArgs, ParsedResponse, StreamEventSink}, retry::{as_transient, into_final_error, response_error, send_error, Backoff, MAX_RETRIES}, types::*, utils::{pinned_ai_client_for, should_use_structured_output_tool}, @@ -585,6 +594,9 @@ fn flow_module_tool_name(summary: Option<&str>) -> Result<&str, Error> { Some(name) if name == WEBSEARCH_ENABLED_NAME => Err(Error::internal_err(format!( "Invalid tool name: {name:?} is reserved for enabling web search" ))), + Some(name) if name == QUESTION_ENABLED_NAME => Err(Error::internal_err(format!( + "Invalid tool name: {name:?} is reserved for the question tool" + ))), Some(name) if TOOL_NAME_REGEX.is_match(name) => Ok(name), other => Err(Error::internal_err(format!("Invalid tool name: {other:?}"))), } @@ -601,6 +613,7 @@ fn tool_enabled_name(tool: &AgentTool) -> Option<&str> { match &tool.value { ToolValue::Mcp(mcp) => Some(mcp.resource_path.trim_start_matches("$res:")), ToolValue::Websearch(_) => Some(WEBSEARCH_ENABLED_NAME), + ToolValue::Question(_) => Some(QUESTION_ENABLED_NAME), _ => tool.summary.as_deref(), } } @@ -898,6 +911,23 @@ pub async fn handle_ai_agent_job( // constrain a shared resource); don't advertise them — invoking one would only fail the depth // check as a third level. let tools = if direct_parent_job_kind == JobKind::AIAgent { + // A nested agent writes no flow status, so nothing would show a question or an approval + // request, and its parent agent holds a worker while it waits: it cannot park either. A + // tool that requires approval is dropped rather than run unapproved. + let (dropped, tools): (Vec<_>, Vec<_>) = + tools.into_iter().partition(|t| t.requires_approval); + if !dropped.is_empty() { + let names: Vec<&str> = dropped + .iter() + .map(|t| tool_enabled_name(t).unwrap_or(&t.id)) + .collect(); + let message = format!( + "Tools that require approval are left out of an agent used as a tool, which cannot \ + wait for an answer: {}\n", + names.join(", ") + ); + append_logs(&job.id, &job.workspace_id, message, conn).await; + } tools .into_iter() .filter(|t| { @@ -905,7 +935,7 @@ pub async fn handle_ai_agent_job( &t.value, ToolValue::FlowModule( FlowModuleValue::AIAgent { .. } | FlowModuleValue::AIDecision { .. } - ) + ) | ToolValue::Question(_) ) }) .collect() @@ -935,8 +965,17 @@ pub async fn handle_ai_agent_job( #[allow(unused_mut)] let mut mcp_configs: Vec<crate::ai::utils::McpResourceConfig> = Vec::new(); let mut has_websearch = false; + let mut question_timeout_secs = None; + // Flow module tool ids and MCP resource paths whose calls each wait for approval. + let mut approval_required: HashSet<String> = HashSet::new(); for tool in tools { + if tool.requires_approval { + approval_required.insert(match &tool.value { + ToolValue::Mcp(mcp) => mcp.resource_path.trim_start_matches("$res:").to_string(), + _ => tool.id.clone(), + }); + } match &tool.value { #[allow(unused_variables)] ToolValue::Mcp(mcp_config) => { @@ -981,6 +1020,9 @@ pub async fn handle_ai_agent_job( tracing::debug!("WebSearch tool enabled"); has_websearch = true; } + ToolValue::Question(question) => { + question_timeout_secs = Some(question_timeout(question)); + } } } @@ -990,6 +1032,7 @@ pub async fn handle_ai_agent_job( let db = db; let job = job; let user_description = tool_descriptions.get(&t.id).cloned(); + let requires_approval = approval_required.contains(&t.id); async move { let summary = flow_module_tool_name(t.summary.as_deref())?; @@ -1124,8 +1167,10 @@ pub async fn handle_ai_agent_job( }), }, }, + requires_approval, module: Some(t), mcp_source: None, + question_timeout_secs: None, }) } })) @@ -1137,11 +1182,21 @@ pub async fn handle_ai_agent_job( let mcp_clients = if !mcp_configs.is_empty() { let (clients, mcp_tools) = load_mcp_tools(db, &job.workspace_id, mcp_configs, client).await?; - tools.extend(mcp_tools); + tools.extend(mcp_tools.into_iter().map(|mut tool| { + tool.requires_approval = tool + .mcp_source + .as_ref() + .is_some_and(|s| approval_required.contains(&s.resource_path)); + tool + })); clients } else { HashMap::new() }; + if let Some(timeout_secs) = question_timeout_secs { + let defs: Vec<ToolDef> = tools.iter().map(|t| t.def.clone()).collect(); + tools.push(question_tool(timeout_secs, &defs)); + } if let Some(enabled) = enabled_tools { let matchable: Vec<&str> = roster_names.iter().map(|s| s.as_str()).collect(); @@ -1265,7 +1320,10 @@ pub async fn handle_ai_agent_job( } }, }; - if result.is_err() { + if result + .as_ref() + .is_err_and(|e| !matches!(e, Error::WacSuspended(_))) + { for handle in tool_abort_handles.lock().unwrap().drain(..) { handle.abort(); } @@ -1275,6 +1333,34 @@ pub async fn handle_ai_agent_job( result } +/// Park the agent on its pending approval, as the error `run_agent` returns. +async fn park_agent( + db: &DB, + conn: &Connection, + job: &MiniPulledJob, + checkpoint: &AgentCheckpoint, + cancel_rx: &tokio::sync::watch::Receiver<bool>, +) -> Error { + match park(db, conn, job, checkpoint).await { + Ok(ParkOutcome::Parked) => Error::WacSuspended(format!( + "AI agent {} parked on {}", + job.id, checkpoint.pending.call_id + )), + Ok(ParkOutcome::Cancelled) => { + // The cancel is on the queue row but not yet seen by the poller, which is what + // completes the job as cancelled rather than failed. Wait for it to notice. + let mut cancel_rx = cancel_rx.clone(); + let _ = tokio::time::timeout( + std::time::Duration::from_secs(30), + cancel_rx.wait_for(|cancelled| *cancelled), + ) + .await; + Error::ExecutionErr("Job cancelled".to_string()) + } + Err(e) => e, + } +} + /// OpenAI rejects a `prompt_cache_key` over 64 characters /// (`Invalid 'prompt_cache_key': string too long`), and a runnable path alone can pass /// that. Fold an over-long key into a digest of itself: same step still yields the same @@ -1380,6 +1466,27 @@ pub async fn run_agent( // Fetch flow context for input transforms context, chat and memory let mut flow_context = get_flow_context(db, job).await; + // Set when the job parked on an approval or a question and has been pulled again. Read + // whatever the roster says now: a linked agent can lose a tool while one of its runs is parked. + let resumed = match load_checkpoint(db, &job.id).await? { + Some(mut checkpoint) => { + let answer = answer_pause( + db, + &job.id, + &checkpoint.pending, + &checkpoint.actions, + &mut checkpoint.consumed_resume_rows, + ) + .await?; + match answer { + Resumed::StillPending => { + return Err(park_agent(db, conn, job, &checkpoint, &cancel_rx).await) + } + Resumed::Answered(message, success) => Some((checkpoint, message, success)), + } + } + None => None, + }; // The run's memory id is also the chat conversation id, which a step's own memory id never // replaces. @@ -1437,7 +1544,7 @@ pub async fn run_agent( return Err(Error::internal_err(missing.to_string())); } - if matches!(output_type, OutputType::Text) { + if matches!(output_type, OutputType::Text) && resumed.is_none() { match &history { HistorySource::Messages(provided) => messages.extend(provided.iter().cloned()), HistorySource::Managed { memory_id, bound } => { @@ -1538,10 +1645,27 @@ pub async fn run_agent( } } - let mut messages = AgentHistory::new(messages); - let mut actions = vec![]; - let mut content = None; - let mut final_usage: Option<TokenUsage> = None; + let mut final_events_str = String::new(); + let mut final_reasoning = String::new(); + let mut first_iteration = 0; + let mut consumed_resume_rows = vec![]; + let mut resumed_answer = None; + let (mut messages, mut actions, mut content, mut final_usage) = match resumed { + Some((checkpoint, message, success)) => { + final_events_str = checkpoint.events; + final_reasoning = checkpoint.reasoning; + first_iteration = checkpoint.next_iteration; + consumed_resume_rows = checkpoint.consumed_resume_rows; + resumed_answer = Some((checkpoint.pending, message, success)); + ( + AgentHistory::from_checkpoint(checkpoint.history), + checkpoint.actions, + checkpoint.content, + checkpoint.usage, + ) + } + None => (AgentHistory::new(messages), vec![], None, None), + }; // Check if this provider supports tools with the current output type let supports_tools = query_builder.supports_tools_with_output_type(output_type); @@ -1596,13 +1720,12 @@ pub async fn run_agent( let user_wants_streaming = streaming_requested(args.streaming); *has_stream = user_wants_streaming && is_text_output; - let mut final_events_str = String::new(); - let mut final_reasoning = String::new(); - // Always create a StreamEventProcessor for text output (use silent mode if user doesn't want streaming) - let stream_event_processor = if is_text_output { + let mut stream_event_processor = if is_text_output { if user_wants_streaming { - Some(StreamEventProcessor::new(conn, job)) + // Each streamed event is one line of the accumulated string. + let streamed = final_events_str.lines().count() as i32; + Some(StreamEventProcessor::new(conn, job, streamed)) } else { Some(StreamEventProcessor::new_silent()) } @@ -1670,8 +1793,81 @@ pub async fn run_agent( windmill_ai::providers::anthropic::FederatedAccessToken, > = None; + if let Some((pending, answer, success)) = resumed_answer { + if let Some(parent_job) = parent_job { + // Rewritten whole rather than appended: a job restarted after its worker died + // resumes from the same checkpoint and must not record the answer twice. + update_flow_status_module_with_actions(db, parent_job, &actions).await?; + set_flow_status_module_last_action_success(db, parent_job, actions.len(), success) + .await?; + } + match (pending.request, answer) { + (PendingRequest::Question { function_name }, Answer::Question(message)) => { + if let (Some(processor), Some(OpenAIContent::Text(result))) = + (stream_event_processor.as_ref(), message.content.as_ref()) + { + let event = StreamingEvent::ToolResult { + call_id: pending.call_id.clone(), + function_name, + result: result.clone(), + success, + }; + processor.send(event, &mut final_events_str).await?; + } + messages.push(message); + } + (PendingRequest::Approval { calls }, answer) => { + let refusal = match &answer { + Answer::Refuse(refusal) => Some(refusal.as_str()), + _ => None, + }; + let tool_execution_ctx = ToolExecutionContext { + db, + conn, + job, + parent_job, + summary: &summary, + flow_step_id_override, + client, + worker_dir, + base_internal_url, + worker_name, + hostname, + occupancy_metrics, + killpill_rx, + stream_event_processor: stream_event_processor.as_ref(), + flow_context: &mut flow_context, + omit_output_from_conversation, + preserve_step_tags, + reasoning: None, + previous_result: &previous_result, + id_context: &id_context, + tool_abort_handles: tool_abort_handles.clone(), + job_completed_tx: job_completed_tx.clone(), + }; + let outcome = execute_tool_calls( + tool_execution_ctx, + &calls, + &tools, + mcp_clients, + &mut actions, + &mut final_events_str, + &structured_output_tool_name, + ApprovalGate::Answered(refusal), + ) + .await?; + messages.extend(outcome.messages); + } + (PendingRequest::Question { .. }, _) => { + return Err(Error::internal_err( + "A question was answered as an approval".to_string(), + )) + } + } + } + // Main agent loop - for i in 0..max_iterations { + for i in first_iteration..max_iterations { // Check if parent was canceled — stop iterating but let current tool calls finish if *cancel_rx.borrow() { return Err(Error::ExecutionErr("Job cancelled".to_string())); @@ -2277,17 +2473,22 @@ pub async fn run_agent( job_completed_tx: job_completed_tx.clone(), }; - let (tool_messages, tool_content, tool_used_structured_output) = - execute_tool_calls( - tool_execution_ctx, - &tool_calls, - &tools, - mcp_clients, - &mut actions, - &mut final_events_str, - &structured_output_tool_name, - ) - .await?; + let ToolCallsOutcome { + messages: tool_messages, + final_content: tool_content, + used_structured_output_tool: tool_used_structured_output, + pause, + } = execute_tool_calls( + tool_execution_ctx, + &tool_calls, + &tools, + mcp_clients, + &mut actions, + &mut final_events_str, + &structured_output_tool_name, + ApprovalGate::Ask, + ) + .await?; messages.extend(tool_messages); @@ -2331,6 +2532,42 @@ pub async fn run_agent( } used_structured_output_tool = tool_used_structured_output; + if let Some(mut pending) = pause { + actions.extend(pending.action.take()); + // The next segment streams from where this one stops, so this one's events + // must be written first. + if let Some(handle) = stream_event_processor.take().and_then(|p| p.to_handle()) + { + let _ = handle.await; + } + let checkpoint = AgentCheckpoint { + history: messages.checkpoint(), + actions: actions.clone(), + content: content.clone(), + usage: final_usage.clone(), + events: final_events_str, + reasoning: final_reasoning, + next_iteration: i + 1, + pending, + consumed_resume_rows, + }; + let parked = park_agent(db, conn, job, &checkpoint, &cancel_rx).await; + // Written once parked: the run page offers an answer only for a request that + // `resume_suspended` can take. The park is committed, so a failure here only + // costs the page its banner. + if let (Error::WacSuspended(_), Some(parent_job)) = (&parked, parent_job) { + if let Err(e) = + update_flow_status_module_with_actions(db, parent_job, &actions).await + { + tracing::error!( + "Failed to show the pending request of {}: {e}", + job.id + ); + } + } + return Err(parked); + } + // Check cancellation after tool calls complete to avoid a wasted LLM call if *cancel_rx.borrow() { return Err(Error::ExecutionErr("Job cancelled".to_string())); @@ -3068,6 +3305,7 @@ mod tests { its.insert(key.to_string(), js(expr)); AgentTool { job_token_scopes: None, + requires_approval: false, id: id.to_string(), summary: None, description: None, @@ -3096,6 +3334,7 @@ mod tests { script_tool("b", "y", "keep_me"), AgentTool { job_token_scopes: None, + requires_approval: false, id: "m".to_string(), summary: None, description: None, @@ -3144,6 +3383,7 @@ mod tests { fn named(id: &str, summary: &str) -> AgentTool { AgentTool { job_token_scopes: None, + requires_approval: false, id: id.to_string(), summary: Some(summary.to_string()), description: None, @@ -3160,6 +3400,7 @@ mod tests { fn mcp(id: &str, summary: &str, path: &str) -> AgentTool { AgentTool { job_token_scopes: None, + requires_approval: false, id: id.to_string(), summary: Some(summary.to_string()), description: None, @@ -3173,6 +3414,7 @@ mod tests { fn websearch(id: &str, summary: Option<&str>) -> AgentTool { AgentTool { job_token_scopes: None, + requires_approval: false, id: id.to_string(), summary: summary.map(str::to_string), description: None, diff --git a/chat-sdk/README.md b/chat-sdk/README.md index 2106d35bee..3c6b4ee5ed 100644 --- a/chat-sdk/README.md +++ b/chat-sdk/README.md @@ -244,6 +244,7 @@ interface ChatState { history: 'server' | 'local' | 'none' loadingMessages: boolean hasMoreMessages: boolean + runningJobId?: string // the flow job the running turn follows } interface ChatMessage { diff --git a/chat-sdk/src/chat.ts b/chat-sdk/src/chat.ts index 2d48d383a0..69832d7ec7 100644 --- a/chat-sdk/src/chat.ts +++ b/chat-sdk/src/chat.ts @@ -305,6 +305,7 @@ class ChatImpl implements Chat { async #followTurn(turn: Turn, isNew: boolean): Promise<void> { let nextTurn: RunningTurn | undefined + this.#set({ runningJobId: turn.jobId }) try { const stopPolling = this.#state.history === 'server' ? this.#startPolling(turn) : () => {} let result: unknown @@ -327,6 +328,7 @@ class ChatImpl implements Chat { this.#failTurn(turn, e) } finally { if (this.#turn === turn) this.#turn = undefined + if (this.#state.runningJobId === turn.jobId) this.#set({ runningJobId: undefined }) } if (nextTurn) await this.resumeTurn(nextTurn) } diff --git a/chat-sdk/src/types.ts b/chat-sdk/src/types.ts index ad9f30cbfb..c6a48574aa 100644 --- a/chat-sdk/src/types.ts +++ b/chat-sdk/src/types.ts @@ -80,6 +80,8 @@ export interface ChatState { history: HistoryMode loadingMessages: boolean hasMoreMessages: boolean + /** The flow job the running turn follows, once it is known. */ + runningJobId?: string } export type TokenSource = string | (() => string | Promise<string>) diff --git a/cli/src/commands/lint/flow_semantics.ts b/cli/src/commands/lint/flow_semantics.ts index 26b72a8867..619f3afed9 100644 --- a/cli/src/commands/lint/flow_semantics.ts +++ b/cli/src/commands/lint/flow_semantics.ts @@ -9,6 +9,12 @@ // frontend/src/lib/components/flows/agentToolUtils.ts applies the same rules. const TOOL_NAME_REGEX = /^[a-zA-Z0-9_]+$/; const WEBSEARCH_ENABLED_NAME = "__wm_web_search"; +// The worker's `*_ENABLED_NAME`s: built-in tools, each named by its reserved name. +const BUILTIN_ENABLED_NAMES = new Set([ + WEBSEARCH_ENABLED_NAME, + "__wm_question", +]); +const BUILTIN_TOOL_TYPES = new Set(["websearch", "question"]); // `forbiddenIds` in frontend/src/lib/components/flows/idUtils.ts. const RESERVED_IDS = new Set([ @@ -134,10 +140,10 @@ function checkAgentTools( if (!Array.isArray(tools)) return; const seen = new Map<string, number>(); tools.forEach((tool: any, i: number) => { - // An mcp entry exposes its server's own tool names and a websearch entry is - // named by `WEBSEARCH_ENABLED_NAME`; neither summary reaches the model. + // An mcp entry exposes its server's own tool names and a built-in entry is + // named by its reserved name; neither summary reaches the model. const kind = tool?.value?.tool_type; - if (kind === "mcp" || kind === "websearch") return; + if (kind === "mcp" || BUILTIN_TOOL_TYPES.has(kind)) return; const at = `${agent.pointer}/value/tools/${i}`; const name = tool?.summary; if (typeof name !== "string" || name.length === 0) { @@ -152,9 +158,9 @@ function checkAgentTools( ); return; } - if (name === WEBSEARCH_ENABLED_NAME) { + if (BUILTIN_ENABLED_NAMES.has(name)) { report.errors.push( - `${at} tool name '${name}' is reserved for enabling web search`, + `${at} tool name '${name}' is reserved for a built-in tool`, ); return; } diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 6a0d6f47f4..c01bad21b7 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5912,7 +5912,10 @@ value: An \`aiagent\` module runs an LLM that can call tools. Each entry of \`value.tools\` is a module-shaped object with an extra \`value.tool_type\`: \`flowmodule\` for a script/flow tool, \`mcp\` for an MCP server -tool, \`websearch\` for web search. +tool, \`websearch\` for web search, \`question\` for a built-in \`ask_question\` tool (the agent stops, +without holding a worker, until someone answers from the run page or the chat; optional +\`value.timeout\` in seconds, default 1800). Set \`requires_approval: true\` on a \`flowmodule\` or \`mcp\` +tool to have a person approve or reject each of its calls before it runs. \`\`\`json { @@ -6008,9 +6011,9 @@ needs becomes unreachable. ### Tool Naming Rules -These rules cover \`flowmodule\` tools, the ones the agent calls by name. A \`websearch\` tool's -\`summary\` is a plain label (\`Web Search\`), and an \`mcp\` tool exposes the MCP server's own tool -names, so neither is name-checked at all — leave those summaries as they are. +These rules cover \`flowmodule\` tools, the ones the agent calls by name. A \`websearch\` or \`question\` +tool's \`summary\` is a plain label (\`Web Search\`), and an \`mcp\` tool exposes the MCP +server's own tool names, so none is name-checked at all — leave those summaries as they are. - A flowmodule tool's \`summary\` is the **name the agent calls it by**, not a human label. Put the human-readable explanation in \`description\` @@ -6455,7 +6458,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 or websearch 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."},"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"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"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"]},"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":["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 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"]}} `, "raw-app": `--- name: raw-app diff --git a/docs/reusable-ai-agents.md b/docs/reusable-ai-agents.md index 5eac5e964c..3d3163470f 100644 --- a/docs/reusable-ai-agents.md +++ b/docs/reusable-ai-agents.md @@ -156,6 +156,51 @@ older editor baked into `memory` stays a fallback behind the run's id until the *Keep as memory id* or *Use the run's memory id*. In a chat flow it is dropped on save, since the conversation id always took precedence there. +## Approval and questions + +Two things park the agent rather than run: a call of a tool with `requires_approval` (flow module +and MCP tools), and a call of the built-in `question` tool, which the model sees as `ask_question`. +Both live in `windmill-worker/src/ai/pause.rs`. + +- **Park.** In a turn that calls such tools, every other call runs right away. The calls that + require approval are gathered into one request, answered together; a question is its own request, + so a turn that also asks for approval has its question refused, as is a second question. The + worker stores the conversation, actions, usage, accumulated stream, next iteration and the + waiting calls in `ai_agent_checkpoint`, then parks the agent's own queue row with + `suspend_wac_parent`: `suspend = 1`, `suspend_until` at the timeout (1800s for an approval, the + question tool's `timeout` otherwise). The job is not completed, its worker is free, and the flow + stays on the step. +- **Answer.** `resume_suspended` with the agent job's id already treats a self-suspended job like a + WAC approval: it inserts the `resume_job` row and decrements, or zeroes on a rejection, the + agent's own `suspend`. The run page offers the form from the pending `approval` or `question` + agent action (Approve/Reject with the calls and their arguments, or the question with its + `choices` and a text answer, sent as a list when `multi_select`), and so does a chat flow's chat, + on the waiting call's tool card while the turn's top-level agent step waits (it polls the turn's + flow job only when a top-level agent step has such a tool or links a saved agent, read off the + flow rather than the stream, which a non-streaming agent leaves empty: the card is then added at + the end of the turn). Both send that request's `call_id` in the payload. There are no signed resume URLs, Slack or Teams messages, + approval page or approver conditions for it: whoever can answer a suspended step without + conditions can answer it. +- **Resume.** The suspended pull takes the row back and `run_agent` finds the checkpoint. In one + transaction it zeroes `suspend`, so `resume_suspended` refuses answers while the agent runs, and + reads the unread `resume_job` rows. The first one naming the parked request (or naming none) + answers it; one naming an earlier request, from a page left open, is read and ignored, and the + agent parks again for the time left. Approved calls then run as any call would; rejected ones, + and those still unanswered at the timeout, are answered as not run, with the approver's comment. + A call whose tool left the roster while parked is answered as no longer available. The agent + then skips memory and the prompt and continues the loop. The step's stream continues at the + offset the first segment reached. Resolution is live here too: a linked agent edited while + parked resumes with its new definition. +- **Cleanup.** The checkpoint row stays while the job is queued, so a job restarted after its + worker died resumes from it, and reads the same answer again: approved calls that ran before + the worker died run again. The monitor deletes rows whose job left the queue; the table has no + foreign key, since one would lock `v2_job_queue` on every write. +- **Limits.** A nested agent tool is never offered the question tool or a tool that requires + approval: it writes no flow status, and its parent holds a worker while it runs. The banner at the + top of the run page shows only a request from a top-level step, as does the chat; one from a step + inside a loop or branch is answered from its graph node. The chat transcript of a chat flow + records neither the request nor its answer. + ## Drafts The agent editor edits the resource through a **per-user resource draft** (`draft` table, diff --git a/frontend/src/lib/components/AgentApprovalRequest.svelte b/frontend/src/lib/components/AgentApprovalRequest.svelte new file mode 100644 index 0000000000..04f7f69430 --- /dev/null +++ b/frontend/src/lib/components/AgentApprovalRequest.svelte @@ -0,0 +1,122 @@ +<script lang="ts"> + import { answerAgentRequest, type AgentRequest } from './agentRequest' + import { Button } from './common' + import TextInput from './text_input/TextInput.svelte' + import ApprovalActions from './approvals/ApprovalActions.svelte' + import { displayDate } from '$lib/utils' + + interface Props { + /** The workspace the job ran in, never the navigation workspace. */ + workspaceId: string + /** The agent step's own job: it is the one parked, so it is the one answered. */ + agentJobId: string + /** Its call id goes with the answer, so a page left open on an earlier request cannot + * answer the one the agent waits on now. */ + request: AgentRequest + /** Whether the request still waits for an answer. */ + pending: boolean + } + + let { workspaceId, agentJobId, request, pending }: Props = $props() + + let text = $state('') + let picked = $state<string[]>([]) + let answered = $state<boolean | undefined>(undefined) + + const choices = $derived(request.type === 'question' ? (request.choices ?? []) : []) + const multiSelect = $derived(request.type === 'question' && !!request.multi_select) + // A typed answer joins the picked choices when several can be picked, and replaces the pick + // otherwise. + const answer = $derived.by((): string | string[] => { + const typed = text.trim() + if (multiSelect) return typed ? [...picked, typed] : picked + return typed || picked[0] || '' + }) + + function pick(choice: string) { + if (multiSelect) { + picked = picked.includes(choice) ? picked.filter((c) => c !== choice) : [...picked, choice] + } else { + picked = [choice] + text = '' + } + } + + async function onAnswer(approved: boolean) { + const value = request.type === 'question' ? answer : text.trim() + await answerAgentRequest(workspaceId, { agentJobId, action: request }, approved, value) + answered = approved + } +</script> + +<div class="flex flex-col gap-2 w-full text-xs"> + {#if request.type === 'question'} + <div class="text-emphasis font-semibold">The agent {pending ? 'asks' : 'asked'} a question</div> + <div class="text-primary whitespace-pre-wrap">{request.question}</div> + {:else} + <div class="text-emphasis font-semibold"> + The agent {pending ? 'asks' : 'asked'} to run {request.calls.length === 1 + ? 'this call' + : 'these calls'} + </div> + {#each request.calls as call, i (i)} + <div class="flex flex-col gap-1"> + <span class="font-mono text-primary">{call.function_name}</span> + <pre + class="text-2xs bg-surface-secondary rounded-md p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all" + >{JSON.stringify(call.arguments, null, 2)}</pre + > + </div> + {/each} + {/if} + {#if answered !== undefined} + <div class="text-secondary"> + {#if request.type === 'question'} + {answered ? 'Answered' : 'Declined'}, the agent resumes shortly. + {:else} + {answered ? 'Approved' : 'Rejected'}, the agent resumes shortly. + {/if} + </div> + {:else if pending} + <div class="text-hint">Expires {displayDate(request.expires_at)}</div> + {#if choices.length > 0} + <div class="flex flex-wrap gap-2"> + {#each choices as choice (choice)} + <Button + variant="default" + unifiedSize="sm" + selected={picked.includes(choice)} + onClick={() => pick(choice)} + > + {choice} + </Button> + {/each} + </div> + {/if} + <TextInput + bind:value={text} + inputProps={{ + placeholder: + request.type === 'question' + ? choices.length > 0 + ? 'Or answer in your own words' + : 'Your answer' + : 'Reason for the agent, if you reject (optional)', + oninput: () => { + if (!multiSelect) picked = [] + } + }} + size="sm" + /> + {#if request.type === 'question'} + <ApprovalActions + approveLabel="Send answer" + rejectTitle="Decline to answer" + approveDisabled={answer.length === 0} + {onAnswer} + /> + {:else} + <ApprovalActions approveLabel="Approve" rejectTitle="Reject" {onAnswer} /> + {/if} + {/if} +</div> diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 489d6c4769..dea1be12f9 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -48,11 +48,14 @@ AI_TOOL_MESSAGE_PREFIX, AI_MCP_TOOL_CALL_PREFIX, AI_WEBSEARCH_PREFIX, + AI_APPROVAL_PREFIX, + AI_QUESTION_PREFIX, getAgentActionStateId, getToolCallId } from './graph/renderers/nodes/AIToolNode.svelte' import JobAssetsViewer from './assets/JobAssetsViewer.svelte' import McpToolCallDetails from './McpToolCallDetails.svelte' + import AgentApprovalRequest from './AgentApprovalRequest.svelte' import GfmMarkdown from './GfmMarkdown.svelte' import JobOtelTraces from './JobOtelTraces.svelte' import JobDetailHeader from './runs/JobDetailHeader.svelte' @@ -780,6 +783,10 @@ job_id: action.job_id, type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress' }) + } else if (action.type == 'approval' || action.type == 'question') { + setModuleState(stateId, { + type: success != undefined ? (success ? 'Success' : 'Failure') : 'WaitingForEvents' + }) } else if (action.type == 'mcp_tool_call') { setModuleState(stateId, { type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress' @@ -2127,6 +2134,25 @@ {/if} {:else if selectedNode?.startsWith(AI_WEBSEARCH_PREFIX)} <Alert type="info" title="Web search was used in this step" /> + {:else if selectedNode?.startsWith(AI_APPROVAL_PREFIX) || selectedNode?.startsWith(AI_QUESTION_PREFIX)} + {@const [, agentModuleId, actionIndex] = selectedNode.split('-')} + {@const agentNode = localModuleStates?.[agentModuleId]} + {@const approvalIndex = parseInt(actionIndex)} + {@const approval = agentNode?.agent_actions?.[approvalIndex]} + {#if (approval?.type === 'approval' || approval?.type === 'question') && agentNode?.job_id} + <div class="p-4"> + <!-- One form per request: its answered state must not carry over to the next. --> + {#key `${agentNode.job_id}-${approval.call_id}`} + <AgentApprovalRequest + workspaceId={job.workspace_id ?? ''} + agentJobId={agentNode.job_id} + request={approval} + pending={job.type === 'QueuedJob' && + agentNode?.agent_actions_success?.[approvalIndex] === undefined} + /> + {/key} + </div> + {/if} {:else if selectedNode} {@const node = localModuleStates[selectedNode]} {#if selectedNode == 'end'} diff --git a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte index 5774e5a2ef..df10e64692 100644 --- a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte +++ b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte @@ -1,11 +1,10 @@ <script lang="ts"> import { mergeSchema } from '$lib/common' import { type Job, JobService } from '$lib/gen' - import { sendUserToast } from '$lib/toast' - import { ExternalLink, X } from 'lucide-svelte' + import { ExternalLink } from 'lucide-svelte' import DisplayResult from './DisplayResult.svelte' import Tooltip from './Tooltip.svelte' - import { Button } from './common' + import ApprovalActions from './approvals/ApprovalActions.svelte' import SchemaForm from './SchemaForm.svelte' import { twMerge } from 'tailwind-merge' import { untrack } from 'svelte' @@ -54,7 +53,6 @@ default_payload = args approvalPageUrl = job_result?.['approvalPage'] - actionTaken = false hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false schema = mergeSchema( job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {}, @@ -62,31 +60,21 @@ ) } - let loading = $state(false) - let actionTaken = $state(false) async function continu(approve: boolean) { if (!workspaceId) { - sendUserToast('Cannot resume: the job has no workspace', true) - return - } - loading = true - try { - await JobService.resumeSuspended({ - workspace: workspaceId, - jobId: job?.id ?? '', - requestBody: { - payload: approve ? (default_payload as any) : undefined, - approved: approve - } - }) - actionTaken = true - onAction?.(approve) - } catch (e: any) { - sendUserToast(e?.body ?? e?.message ?? 'Failed', true) - } finally { - loading = false + throw new Error('Cannot resume: the job has no workspace') } + await JobService.resumeSuspended({ + workspace: workspaceId, + jobId: job?.id ?? '', + requestBody: { + payload: approve ? (default_payload as any) : undefined, + approved: approve + } + }) + onAction?.(approve) } + let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1) // Everything this panel shows (description, resume form enums, approval page) is // fetched from the suspended job — a recording carries none of it — and Resume / @@ -108,34 +96,16 @@ <DisplayResult {workspaceId} noControls result={description} language={job?.language} /> <div class="mt-2"></div> {/if} - <div> - <div class={twMerge('flex gap-2 items-center', light ? 'flex-col' : 'flex-row ')}> - {#if !hide_cancel} - <div> - <Button - title="Cancel the step" - iconOnly - startIcon={{ icon: X }} - variant="default" - disabled={loading || actionTaken} - destructive - unifiedSize="md" - on:click={() => continu(false)} - /> - </div> - {/if} - <div> - <Button - variant="accent" - onClick={() => continu(true)} - disabled={loading || actionTaken} - unifiedSize="md" - > - Resume - <Tooltip class="text-white">Resume or approve this suspended step</Tooltip> - </Button> - </div> - + <!-- A new approval step gets usable buttons again. --> + {#key job?.flow_status?.modules?.[approvalStep]?.job} + <ApprovalActions + approveLabel="Resume" + approveTooltip="Resume or approve this suspended step" + rejectTitle="Cancel the step" + hideReject={hide_cancel} + {light} + onAnswer={continu} + > {#if approvalPageUrl} <a href={approvalPageUrl} @@ -161,7 +131,7 @@ variable </Tooltip> {/if} - </div> - </div> + </ApprovalActions> + {/key} </div> {/if} diff --git a/frontend/src/lib/components/JobStatus.svelte b/frontend/src/lib/components/JobStatus.svelte index 5e79303371..30fd5f0a27 100644 --- a/frontend/src/lib/components/JobStatus.svelte +++ b/frontend/src/lib/components/JobStatus.svelte @@ -2,6 +2,7 @@ import { displayDate, msToReadableTime } from '$lib/utils' import type { CompletedJob, QueuedJob } from '$lib/gen' import Badge from './common/badge/Badge.svelte' + import { isSuspended } from './agentRequest' import { forLater } from '$lib/forLater' import { Circle } from 'lucide-svelte' import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte' @@ -58,7 +59,7 @@ /> {/if} </Badge> -{:else if job && 'running' in job && job.running && job.suspend} +{:else if job && 'running' in job && job.running && isSuspended(job)} <div> <Badge {large} color="violet"> Suspended diff --git a/frontend/src/lib/components/agentRequest.ts b/frontend/src/lib/components/agentRequest.ts new file mode 100644 index 0000000000..879cec08a3 --- /dev/null +++ b/frontend/src/lib/components/agentRequest.ts @@ -0,0 +1,60 @@ +import { JobService, type FlowStatus, type FlowStatusModule } from '$lib/gen' + +export type AgentRequest = Extract< + NonNullable<FlowStatusModule['agent_actions']>[number], + { type: 'approval' | 'question' } +> + +/** The approval or question an agent step's actions end on, while nobody has answered it. */ +export function waitingRequest( + mod: Pick<FlowStatusModule, 'agent_actions' | 'agent_actions_success'> +) { + const actions = mod.agent_actions + const last = actions?.[actions.length - 1] + if ( + !last || + (last.type !== 'approval' && last.type !== 'question') || + mod.agent_actions_success?.[actions!.length - 1] !== undefined + ) { + return undefined + } + return last +} + +/** The approval or question a flow's current agent step is parked on: the step stays in + * progress while its own job waits. */ +export function pendingAgentApproval(job: { flow_status?: FlowStatus } | undefined) { + const mod = job?.flow_status?.modules?.[job?.flow_status?.step] + if (mod?.type !== 'InProgress' || !mod.job) return undefined + const action = waitingRequest(mod) + return action && { agentJobId: mod.job, action } +} + +/** Suspended, as the run's badge and icon say: an approval step, or an agent waiting on a person. */ +export function isSuspended(job: { suspend?: number; flow_status?: FlowStatus }) { + return !!job.suspend || !!pendingAgentApproval(job) +} + +export type PendingAgentRequest = NonNullable<ReturnType<typeof pendingAgentApproval>> + +/** `answer` is the question's answer, a list when several choices were picked, or the reason given + * with a rejection. Its call id goes with the answer, so a page left open on an earlier request + * cannot answer the one waiting now. */ +export async function answerAgentRequest( + workspace: string, + { agentJobId, action }: PendingAgentRequest, + approved: boolean, + answer?: string | string[] +) { + await JobService.resumeSuspended({ + workspace, + jobId: agentJobId, + requestBody: { + approved, + payload: { + call_id: action.call_id, + ...(answer?.length ? { [action.type === 'question' ? 'answer' : 'comment']: answer } : {}) + } + } + }) +} diff --git a/frontend/src/lib/components/agentTrace.ts b/frontend/src/lib/components/agentTrace.ts index 392e8b00c9..013d4a65ac 100644 --- a/frontend/src/lib/components/agentTrace.ts +++ b/frontend/src/lib/components/agentTrace.ts @@ -90,6 +90,15 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] { entries.push({ kind: 'search' }) continue } + if (action?.type === 'question') { + entries.push({ + kind: 'tool', + name: 'Ask a question', + args: message.tool_call_id ? argsByCallId.get(message.tool_call_id) : undefined, + result: contentText(message.content) + }) + continue + } // Every message this run produced is tagged, including the agent narrating // its next move and its final answer. Untagged ones are the prompt, the // question, or a previous turn replayed out of memory — history loses its diff --git a/frontend/src/lib/components/approvals/ApprovalActions.svelte b/frontend/src/lib/components/approvals/ApprovalActions.svelte new file mode 100644 index 0000000000..f55d6a5e49 --- /dev/null +++ b/frontend/src/lib/components/approvals/ApprovalActions.svelte @@ -0,0 +1,76 @@ +<script lang="ts"> + import type { Snippet } from 'svelte' + import { X } from 'lucide-svelte' + import { twMerge } from 'tailwind-merge' + import { sendUserToast } from '$lib/toast' + import { Button } from '../common' + import Tooltip from '../Tooltip.svelte' + + interface Props { + approveLabel: string + approveTooltip?: string + rejectTitle: string + hideReject?: boolean + /** Stacks the buttons, for the graph's popover. */ + light?: boolean + /** Holds the approve button back, e.g. until an answer is typed. */ + approveDisabled?: boolean + /** Throws to report a failure; the buttons stay usable then. */ + onAnswer: (approved: boolean) => Promise<void> + /** Shown after the buttons, on the same row. */ + children?: Snippet + } + + let { + approveLabel, + approveTooltip, + rejectTitle, + hideReject = false, + light = false, + approveDisabled = false, + onAnswer, + children + }: Props = $props() + + let loading = $state(false) + let answered = $state(false) + + async function answer(approved: boolean) { + loading = true + try { + await onAnswer(approved) + answered = true + } catch (e: any) { + sendUserToast(e?.body ?? e?.message ?? 'Failed', true) + } finally { + loading = false + } + } +</script> + +<div class={twMerge('flex gap-2 items-center', light ? 'flex-col' : 'flex-row')}> + {#if !hideReject} + <Button + title={rejectTitle} + iconOnly + startIcon={{ icon: X }} + variant="default" + disabled={loading || answered} + destructive + unifiedSize="md" + onClick={() => answer(false)} + /> + {/if} + <Button + variant="accent" + onClick={() => answer(true)} + disabled={loading || answered || approveDisabled} + unifiedSize="md" + > + {approveLabel} + {#if approveTooltip} + <Tooltip class="text-white">{approveTooltip}</Tooltip> + {/if} + </Button> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index c9d3322bdb..fc9911edf2 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.830.0","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 or websearch 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."},"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"}]},"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"]},"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"}}},"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).\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":["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 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 diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts index d83ef70207..0ead3cee63 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts @@ -1,7 +1,7 @@ import { z } from "zod" -export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional(), "model": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "reasoning_effort": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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 or websearch tool it is a plain label. Put the human-readable explanation in 'description'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { - const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; +export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional(), "model": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "reasoning_effort": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "requires_approval": z.boolean().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { + const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool"), z.object({ "tool_type": z.literal("question"), "timeout": z.number().int().describe("Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800.").optional() }).describe("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.")]; const errors = schemas.reduce<z.ZodError[]>( (errors, schema) => ((result) => @@ -20,8 +20,8 @@ export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "i }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("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")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type") -export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional(), "model": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "reasoning_effort": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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 or websearch tool it is a plain label. Put the human-readable explanation in 'description'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { - const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; +export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional(), "model": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "reasoning_effort": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "requires_approval": z.boolean().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { + const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool"), z.object({ "tool_type": z.literal("question"), "timeout": z.number().int().describe("Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800.").optional() }).describe("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.")]; const errors = schemas.reduce<z.ZodError[]>( (errors, schema) => ((result) => diff --git a/frontend/src/lib/components/flows/FlowProgressBar.svelte b/frontend/src/lib/components/flows/FlowProgressBar.svelte index 5edf55b5de..8b3a966d57 100644 --- a/frontend/src/lib/components/flows/FlowProgressBar.svelte +++ b/frontend/src/lib/components/flows/FlowProgressBar.svelte @@ -2,6 +2,7 @@ import { type Job } from '$lib/gen' import ProgressBar from '../progressBar/ProgressBar.svelte' import { forLater } from '$lib/forLater' + import { waitingRequest } from '../agentRequest' import type { Snippet } from 'svelte' interface Props { @@ -79,7 +80,13 @@ if (modules.length > maxDone) { const nextModule = modules[maxDone] - if (nextModule.type === 'InProgress') { + if (nextModule.type === 'InProgress' && waitingRequest(nextModule)) { + // An agent waiting on a person reads like an approval step. + newIsWaitingForEvents = true + if (nextModule.id) { + newCurrentStepId = nextModule.id + } + } else if (nextModule.type === 'InProgress') { newNextInProgress = true // Get the step ID if available if (nextModule.id) { diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index aef204784d..5412c8728f 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -24,7 +24,7 @@ import { // Lives here rather than beside the other config helpers: `agentResourceUtils` is a leaf, and // importing tool-name validation into it would cycle back through `flowInfers` and pull the whole // editor into every module that reads a brain key. -import { getToolNameError } from './agentToolUtils' +import { getToolNameError, isNamedTool, type AgentTool } from './agentToolUtils' /** * Why a run of this config would fail before it started, if it would. Only the conditions the @@ -49,12 +49,10 @@ function agentConfigRunError(args: Record<string, any> | undefined): string | un return 'Tools must be a list. Fix it in the resource editor before deploying.' } // Only a flowmodule tool's summary is a callable name: the worker never reads an mcp or - // websearch summary, so a blank one there is not an error and must not count as a sibling. - // Same filter as `collectInvalidAgentToolNames`, which is the rule the graph enforces. + // built-in tool's summary, so a blank one there is not an error and must not count as a + // sibling. Same filter as `collectInvalidAgentToolNames`, which is the rule the graph enforces. const tools = (args?.tools ?? []) as Record<string, any>[] - const named = tools.filter( - (t) => t?.value?.tool_type !== 'mcp' && t?.value?.tool_type !== 'websearch' - ) + const named = tools.filter((t) => isNamedTool(t as AgentTool)) const siblingNames = named.map((t) => t?.summary ?? '') for (const tool of named) { const err = getToolNameError(tool?.summary ?? '', tool?.value?.tool_type, siblingNames) diff --git a/frontend/src/lib/components/flows/agentToolTree.ts b/frontend/src/lib/components/flows/agentToolTree.ts index 081eeed467..45fefbe194 100644 --- a/frontend/src/lib/components/flows/agentToolTree.ts +++ b/frontend/src/lib/components/flows/agentToolTree.ts @@ -2,6 +2,7 @@ import type { FlowModule } from '$lib/gen' import { getToolNameError, isFlowModuleTool, + isNamedTool, type AgentTool, type FlowModuleTool } from './agentToolUtils' @@ -195,10 +196,8 @@ export function collectInvalidAgentToolNames(modules: unknown): InvalidAgentTool forEachAiAgentModule(modules, (mod, v) => { const tools: AgentTool[] = Array.isArray(v.tools) ? v.tools : [] // Only a flowmodule tool's summary is a callable name: the worker never reads an mcp or - // websearch summary, so a blank one there must stay writable (both default to ''). - const named = tools.filter( - (tool) => tool.value?.tool_type !== 'mcp' && tool.value?.tool_type !== 'websearch' - ) + // built-in tool's summary, so a blank one there must stay writable. + const named = tools.filter(isNamedTool) const siblingNames = named.map((tool) => tool.summary ?? '') for (const tool of named) { const name = tool.summary ?? '' diff --git a/frontend/src/lib/components/flows/agentToolUtils.test.ts b/frontend/src/lib/components/flows/agentToolUtils.test.ts index 6ff47db176..6a7ec84568 100644 --- a/frontend/src/lib/components/flows/agentToolUtils.test.ts +++ b/frontend/src/lib/components/flows/agentToolUtils.test.ts @@ -49,4 +49,11 @@ describe('toolEnabledName', () => { `'${WEBSEARCH_ENABLED_NAME}' is a reserved name` ) }) + + it("names the question tool by the worker's reserved name", () => { + expect( + toolEnabledName({ id: 't', summary: 'label', value: { tool_type: 'question' } } as any) + ).toBe('__wm_question') + expect(getToolNameError('__wm_question')).toBe(`'__wm_question' is a reserved name`) + }) }) diff --git a/frontend/src/lib/components/flows/agentToolUtils.ts b/frontend/src/lib/components/flows/agentToolUtils.ts index 74813cd0a4..643d5d05cd 100644 --- a/frontend/src/lib/components/flows/agentToolUtils.ts +++ b/frontend/src/lib/components/flows/agentToolUtils.ts @@ -10,6 +10,26 @@ import { forbiddenIds } from './idUtils' * search. */ export const WEBSEARCH_ENABLED_NAME = '__wm_web_search' +/** Tools the worker answers itself rather than by running a module. None is named by its + * `summary`: each has a reserved `enabledName` (mirroring the `*_ENABLED_NAME`s in the worker), + * and the model sees a fixed name the worker picks. `question` parks the agent until someone + * answers (`windmill-worker/src/ai/pause.rs`). */ +export const BUILTIN_TOOLS = { + websearch: { label: 'Web search', enabledName: WEBSEARCH_ENABLED_NAME }, + question: { label: 'Ask a question', enabledName: '__wm_question' } +} as const +export type BuiltinToolType = keyof typeof BUILTIN_TOOLS + +export function builtinToolType(tool: AgentTool | undefined): BuiltinToolType | undefined { + const type = tool?.value?.tool_type + return type && type in BUILTIN_TOOLS ? (type as BuiltinToolType) : undefined +} + +/** Whether a tool is named by its `summary`, which only the model-facing kinds are. */ +export function isNamedTool(tool: AgentTool | undefined): boolean { + return tool?.value?.tool_type !== 'mcp' && builtinToolType(tool) === undefined +} + /** * A tool's `summary` is the name the LLM sees, and the worker rejects any name that does not match * `^[a-zA-Z0-9_]+$` (`ai_executor.rs`), so an unvalidated name fails on every run of the flow. @@ -21,10 +41,10 @@ export const WEBSEARCH_ENABLED_NAME = '__wm_web_search' */ export function getToolNameError( name: string, - kind?: 'mcp' | 'websearch' | (string & {}), + kind?: 'mcp' | BuiltinToolType | (string & {}), siblingNames?: string[] ): string | undefined { - if (kind === 'websearch') return undefined + if (kind && kind in BUILTIN_TOOLS) return undefined if (kind === 'mcp') { return name.length > 0 ? undefined : 'Tool name must not be empty' } @@ -36,7 +56,10 @@ export function getToolNameError( if (!/^[a-zA-Z0-9_]+$/.test(name)) { return 'Tool name must only contain letters, numbers and underscores' } - if (forbiddenIds.includes(name) || name === WEBSEARCH_ENABLED_NAME) { + if ( + forbiddenIds.includes(name) || + Object.values(BUILTIN_TOOLS).some((builtin) => builtin.enabledName === name) + ) { return `'${name}' is a reserved name` } if (siblingNames && siblingNames.filter((n) => n === name).length > 1) { @@ -48,6 +71,7 @@ export function getToolNameError( export const SPECIAL_TOOL_KINDS = [ 'mcpTool', 'websearchTool', + 'questionTool', 'aiAgentTool', 'aiDecisionTool' ] as const @@ -154,8 +178,9 @@ export function toolEnabledName(tool: AgentTool): string | undefined { if (value?.tool_type === 'mcp') { return (value?.resource_path as string | undefined)?.replace(/^\$res:/, '') || undefined } - if (value?.tool_type === 'websearch') { - return WEBSEARCH_ENABLED_NAME + const builtin = builtinToolType(tool) + if (builtin) { + return BUILTIN_TOOLS[builtin].enabledName } return toolDisplayName(tool) } @@ -233,13 +258,17 @@ export function createMcpTool(id: string): McpTool { export function createWebsearchTool(id: string): WebsearchTool { return { id, - summary: 'Web Search', + summary: 'Web search', value: { tool_type: 'websearch' } } } +export function createQuestionTool(id: string): AgentTool { + return { id, summary: BUILTIN_TOOLS.question.label, value: { tool_type: 'question' } } +} + /** * Convert a FlowModuleTool to a FlowModule for use with loadFlowModuleState etc. * Strips the extra `tool_type` field and maps AgentTool fields to FlowModule fields. diff --git a/frontend/src/lib/components/flows/content/AgentConfigModal.svelte b/frontend/src/lib/components/flows/content/AgentConfigModal.svelte index 5fe009b57b..fa052d00ac 100644 --- a/frontend/src/lib/components/flows/content/AgentConfigModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentConfigModal.svelte @@ -15,7 +15,12 @@ is that one, since both answer the same question about what an assistant can see summarizeAgentBrain, type AIAgentConfig } from '../agentResourceUtils' - import { toolDisplayName, type AgentTool } from '../agentToolUtils' + import { + BUILTIN_TOOLS, + builtinToolType, + toolDisplayName, + type AgentTool + } from '../agentToolUtils' type Section = 'model' | 'instructions' | 'tools' @@ -71,7 +76,8 @@ is that one, since both answer the same question about what an assistant can see function toolKind(tool: AgentTool): string { const value = tool?.value as Record<string, any> | undefined if (value?.tool_type === 'mcp') return 'MCP server' - if (value?.tool_type === 'websearch') return 'Web search' + const builtin = builtinToolType(tool) + if (builtin) return BUILTIN_TOOLS[builtin].label switch (value?.type) { case 'script': return `Script ${value.path ?? ''}`.trim() diff --git a/frontend/src/lib/components/flows/content/AgentToolRoster.svelte b/frontend/src/lib/components/flows/content/AgentToolRoster.svelte index 7851385465..80b36af3e9 100644 --- a/frontend/src/lib/components/flows/content/AgentToolRoster.svelte +++ b/frontend/src/lib/components/flows/content/AgentToolRoster.svelte @@ -1,7 +1,13 @@ <script lang="ts"> import { ChevronRight, Plus, Wrench, X } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' - import { getToolNameError, toolDisplayName } from '../agentToolUtils' + import { + BUILTIN_TOOLS, + builtinToolType, + getToolNameError, + isNamedTool, + toolDisplayName + } from '../agentToolUtils' import type { AgentTool } from '../agentToolUtils' import { Button } from '$lib/components/common' import Popover from '$lib/components/meltComponents/Popover.svelte' @@ -42,15 +48,14 @@ if (value?.type === 'aiagent') return 'agent' if (value?.type === 'aidecision') return 'decision' if (value?.tool_type === 'mcp') return 'MCP' - if (value?.tool_type === 'websearch') return 'web search' + const builtin = builtinToolType(tool) + if (builtin) return BUILTIN_TOOLS[builtin].label.toLowerCase() return undefined } - /** Only a flow module tool's summary is the name the model is given; an mcp or websearch + /** Only a flow module tool's summary is the name the model is given; an mcp or built-in tool's * summary is never sent, so a blank one there is not an error. Same rule as `agentToolTree`. */ - function isNamed(tool: AgentTool): boolean { - return tool?.value?.tool_type !== 'mcp' && tool?.value?.tool_type !== 'websearch' - } + const isNamed = isNamedTool let siblingNames = $derived(tools.filter(isNamed).map((tool) => tool?.summary ?? '')) @@ -103,6 +108,7 @@ )} on:pickMcpTool={() => (onAddTool?.({ kind: 'mcpTool' }), close())} on:pickWebsearchTool={() => (onAddTool?.({ kind: 'websearchTool' }), close())} + on:pickQuestionTool={() => (onAddTool?.({ kind: 'questionTool' }), close())} on:pickAiAgentTool={() => (onAddTool?.({ kind: 'aiAgentTool' }), close())} on:pickAiDecisionTool={() => (onAddTool?.({ kind: 'aiDecisionTool' }), close())} /> diff --git a/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte b/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte index 69a4acf74e..594ab97f34 100644 --- a/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte +++ b/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte @@ -5,6 +5,7 @@ import FlowModuleComponent from './FlowModuleComponent.svelte' import McpToolEditor from './McpToolEditor.svelte' import WebsearchToolDisplay from './WebsearchToolDisplay.svelte' + import QuestionToolDisplay from './QuestionToolDisplay.svelte' interface Props { tool: AgentTool @@ -62,6 +63,9 @@ isAgentTool={true} {staticOnly} bind:toolDescription={tool.description} + bind:toolRequiresApproval={ + () => tool.requires_approval, (v) => (tool.requires_approval = v || undefined) + } {siblingToolNames} /> {:else if isMcpTool(tool)} @@ -69,4 +73,6 @@ <McpToolEditor bind:tool {noEditor} /> {:else if isWebsearchTool(tool)} <WebsearchToolDisplay {noEditor} /> +{:else if tool.value?.tool_type === 'question'} + <QuestionToolDisplay bind:tool {noEditor} /> {/if} diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index fa64fad44d..fb2e0bcb1f 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -71,7 +71,8 @@ signDebugRequest, getDebugErrorMessage } from '$lib/components/debug' - import { Bug, Terminal } from 'lucide-svelte' + import { Bug, Hand, Terminal } from 'lucide-svelte' + import Popover from '$lib/components/Popover.svelte' import { sendUserToast } from '$lib/utils' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -133,6 +134,8 @@ /** Lets the agent's Tools section add a tool through the graph's own insert path. */ flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default toolDescription?: string | undefined + /** The tool's `requires_approval` setting; only an agent tool has one. */ + toolRequiresApproval?: boolean siblingToolNames?: string[] } @@ -153,6 +156,7 @@ staticOnly = false, flowModuleSchemaMap = undefined, toolDescription = $bindable(undefined), + toolRequiresApproval = $bindable(), siblingToolNames = undefined }: Props = $props() @@ -1149,6 +1153,20 @@ label="Chat" /> {/if} + {#if isAgentTool} + <Tab value="tool_settings" label="Tool settings"> + {#snippet extra()} + {#if toolRequiresApproval} + <div class="flex flex-row items-center gap-1 text-secondary"> + <Popover notClickable> + <Hand size={12} /> + {#snippet text()}Require approval · On{/snippet} + </Popover> + </div> + {/if} + {/snippet} + </Tab> + {/if} {#if !preprocessorModule && !isAgentTool} <Tab value="advanced" label="Run settings"> {#snippet extra()} @@ -1395,6 +1413,24 @@ /> </Section> </div> + {:else if visibleSelected === 'tool_settings' && isAgentTool} + <div class="flex-1 overflow-auto p-4"> + <Section label="Approval"> + <Toggle + size="xs" + textClass="text-xs font-normal text-primary" + disabled={noEditor} + bind:checked={ + () => toolRequiresApproval ?? false, (v) => (toolRequiresApproval = v) + } + options={{ + right: 'Require approval', + rightTooltip: + 'Each call waits for someone to approve or reject it from the run page or the chat, without holding a worker. Other calls in the same turn run right away. A rejected call is reported to the agent as not run. An agent used as a tool of another agent cannot wait, so it leaves such tools out.' + }} + /> + </Section> + </div> {:else if visibleSelected === 'advanced'} <FlowRunSettings bind:this={runSettings} diff --git a/frontend/src/lib/components/flows/content/McpToolEditor.svelte b/frontend/src/lib/components/flows/content/McpToolEditor.svelte index f69aa25d1d..18d69dcb63 100644 --- a/frontend/src/lib/components/flows/content/McpToolEditor.svelte +++ b/frontend/src/lib/components/flows/content/McpToolEditor.svelte @@ -31,6 +31,7 @@ import McpConnect from '$lib/components/mcp/McpConnect.svelte' import type { FlowEditorContext } from '../types' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import Toggle from '$lib/components/Toggle.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -126,6 +127,19 @@ /> </Label> </div> + <Toggle + size="xs" + textClass="text-xs font-normal text-primary" + disabled={noEditor} + bind:checked={ + () => tool.requires_approval ?? false, (v) => (tool.requires_approval = v || undefined) + } + options={{ + right: 'Require approval', + rightTooltip: + 'Each call waits for someone to approve or reject it from the run page or the chat, without holding a worker. Other calls in the same turn run right away. A rejected call is reported to the agent as not run. An agent used as a tool of another agent cannot wait, so it leaves such tools out.' + }} + /> <Section label="Available Tools"> {#snippet action()} diff --git a/frontend/src/lib/components/flows/content/QuestionToolDisplay.svelte b/frontend/src/lib/components/flows/content/QuestionToolDisplay.svelte new file mode 100644 index 0000000000..c07033b264 --- /dev/null +++ b/frontend/src/lib/components/flows/content/QuestionToolDisplay.svelte @@ -0,0 +1,32 @@ +<script lang="ts"> + import { SecondsInput } from '$lib/components/common' + import Label from '$lib/components/Label.svelte' + import FlowCard from '../common/FlowCard.svelte' + import { BUILTIN_TOOLS, type AgentTool } from '../agentToolUtils' + + /** Matches `DEFAULT_TIMEOUT_SECS` in `windmill-worker/src/ai/pause.rs`. */ + const DEFAULT_QUESTION_TIMEOUT_SECS = 1800 + + let { tool = $bindable(), noEditor = false }: { tool: AgentTool; noEditor?: boolean } = $props() + + let value = $derived(tool.value as { tool_type: 'question'; timeout?: number }) +</script> + +<FlowCard + {noEditor} + title={BUILTIN_TOOLS.question.label} + subtitle="Lets the agent ask a person and wait for their answer, from the run page or the chat." + subtitleDocLink="https://www.windmill.dev/docs/core_concepts/ai_agents" +> + <div class="flex flex-col gap-4 overflow-auto p-4" style="scrollbar-gutter: stable"> + <Label label="Timeout"> + <p class="text-xs font-normal text-secondary">Past it, the agent is told nobody answered</p> + <SecondsInput + disabled={noEditor} + bind:seconds={ + () => value.timeout ?? DEFAULT_QUESTION_TIMEOUT_SECS, (v) => (value.timeout = v) + } + /> + </Label> + </div> +</FlowCard> diff --git a/frontend/src/lib/components/flows/content/WebsearchToolDisplay.svelte b/frontend/src/lib/components/flows/content/WebsearchToolDisplay.svelte index 2d679ff665..13d229ca97 100644 --- a/frontend/src/lib/components/flows/content/WebsearchToolDisplay.svelte +++ b/frontend/src/lib/components/flows/content/WebsearchToolDisplay.svelte @@ -1,15 +1,12 @@ <script lang="ts"> - import Alert from '$lib/components/common/alert/Alert.svelte' import FlowCard from '../common/FlowCard.svelte' let { noEditor = false }: { noEditor?: boolean } = $props() </script> -<FlowCard {noEditor} title="Web search"> - <div class="flex flex-col gap-4 overflow-auto p-4" style="scrollbar-gutter: stable"> - <Alert title="Web search tool" type="info"> - Gives the AI Agent the ability to search the web. Only works for openai, anthropic and google - models for now. - </Alert> - </div> -</FlowCard> +<FlowCard + {noEditor} + title="Web search" + subtitle="Works with OpenAI, Anthropic and Google models." + subtitleDocLink="https://www.windmill.dev/docs/core_concepts/ai_agents" +/> diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index 52098d33ee..15f9f97831 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -12,7 +12,7 @@ import { tick, untrack } from 'svelte' import type { Chat } from 'windmill-chat' import { saveFlowChatInputs } from './flowChatProps' - import { ResourceService, type FlowModule } from '$lib/gen' + import { JobService, ResourceService, type FlowModule } from '$lib/gen' import { resource } from 'runed' import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte' import { @@ -22,6 +22,10 @@ } from './agentAttachmentInput' import { deepEqual } from 'fast-equals' import FlowChatModelSettings from './FlowChatModelSettings.svelte' + import { + pendingAgentApproval, + type PendingAgentRequest + } from '$lib/components/agentRequest' import { agentModelGap, chatLinkedAgentPaths, @@ -202,7 +206,8 @@ sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason, // The model controls only: a retry changes model when the reader did, but replays // the run's own attachments rather than whatever the composer holds now. - inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined) + inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined), + agentRequest: () => agentRequest }) setChatViewHost(chatHost) @@ -248,6 +253,49 @@ }) }) + // Only a flow that can ask a person something reads its running turn's status. Read off the + // flow, not the stream: an agent that does not stream sends no tool call. A linked agent's + // tools are not loaded here, so it counts; only a top-level step's request is answered from + // the chat. + const mayRequestApproval = $derived( + (flowModules ?? []).some((module) => { + const value = module.value as any + return ( + value?.type === 'aiagent' && + (!!value.agent || + value.tools?.some( + (tool: any) => tool?.requires_approval || tool?.value?.tool_type === 'question' + )) + ) + }) + ) + const approvalJobId = $derived(mayRequestApproval ? chatHost.state.runningJobId : undefined) + + // The stream only says a call is running; the request to answer, and the job that answers + // it, are on the flow's status. + let agentRequest = $state<PendingAgentRequest | undefined>(undefined) + $effect(() => { + const id = approvalJobId + agentRequest = undefined + if (!id || !workspace) return + const args = { workspace, id, noLogs: true } + let stopped = false + const load = async () => { + try { + const job = await JobService.getJob(args) + if (!stopped) agentRequest = pendingAgentApproval(job) + } catch { + // transient; the next poll reads it again + } + } + void load() + const interval = setInterval(load, 2000) + return () => { + stopped = true + clearInterval(interval) + } + }) + // Older pages load when the reader reaches the top; the viewport stays where it was. let scrollElement = $state<HTMLDivElement | undefined>(undefined) let loadingOlder = false diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index 6009d73ccd..d9ff62d8e3 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -18,7 +18,11 @@ import type { ChatSendRequestOptions, ChatViewHost } from '$lib/components/copilot/chat/chatViewHost' -import type { DisplayMessage, WebSearchResult } from '$lib/components/copilot/chat/shared' +import type { + DisplayMessage, + ToolDisplayMessage, + WebSearchResult +} from '$lib/components/copilot/chat/shared' import type { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte' import { AIWorkMode } from '$lib/components/copilot/chat/workMode' import { isPlanCardTool } from '$lib/components/copilot/chat/planMode' @@ -36,6 +40,11 @@ import { JobService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { attachmentLanes } from './messageAttachments' import type { AttachmentsTarget } from './agentAttachmentInput' +import { + answerAgentRequest, + type AgentRequest, + type PendingAgentRequest +} from '$lib/components/agentRequest' export type FlowChatViewHostOptions = { /** The flow inputs sent next to `user_message` with every turn. */ @@ -55,6 +64,8 @@ export type FlowChatViewHostOptions = { /** Flow inputs edited by a control beside the composer, such as the model button. A retry * takes their current value rather than the failed turn's. */ inputsShownInComposer?: () => string[] + /** The approval or question the running turn's agent step waits on. */ + agentRequest?: () => PendingAgentRequest | undefined /** Injectables for tests: the clock and scheduler behind the typewriter pacing. */ revealOptions?: Pick<TypewriterRevealOptions, 'instant' | 'now' | 'schedule' | 'cancel'> } @@ -133,6 +144,57 @@ export type DisplayLookups = { workspace?: string /** How much of a pending assistant row the pacing has put on screen. */ revealed?: (message: ChatMessage) => Revealed | undefined + /** The approval or question the running turn's agent waits on. */ + request?: AgentRequest +} + +const QUESTION_CALL = /^ask_question(_\d+)?$/ + +/** The calls a request holds, as the cards the copilot shows for a confirmation or a question. + * `row` is the streamed card of that call, if any: an agent that does not stream sends none. */ +function requestCard( + request: AgentRequest, + toolName: string, + row: ToolDisplayMessage +): ToolDisplayMessage { + if (request.type === 'question') { + return { + ...row, + content: 'Waiting for your answer', + isLoading: true, + userQuestion: { + question: request.question, + choices: request.choices ?? [], + multiSelect: request.multi_select + } + } + } + const call = request.calls.find((c) => c.function_name === toolName) + const others = request.calls.length - 1 + return { + ...row, + content: + `Waiting for approval to run ${toolName}` + + (others > 0 ? `, answered together with ${others} other call${others > 1 ? 's' : ''}` : ''), + parameters: row.parameters ?? call?.arguments, + showDetails: true, + isLoading: true, + needsConfirmation: true, + confirmAction: 'run' + } +} + +function requestKey({ agentJobId, action }: PendingAgentRequest): string { + return `${agentJobId}:${action.call_id}` +} + +function holds(request: AgentRequest, toolName: string | undefined): toolName is string { + return ( + !!toolName && + (request.type === 'question' + ? QUESTION_CALL.test(toolName) + : request.calls.some((c) => c.function_name === toolName)) + ) } /** @@ -196,7 +258,9 @@ export function toDisplayMessages( for (let i = messages.length - 1; i >= 0 && latestUser < 0; i--) { if (messages[i].role === 'user') latestUser = i } - return messages.flatMap((message, i): DisplayMessage[] => { + const request = lookups.request + let requestShown = false + const shown = messages.flatMap((message, i): DisplayMessage[] => { switch (message.role) { case 'user': { const index = userIndex++ @@ -253,7 +317,7 @@ export function toDisplayMessages( !STRUCTURED_OUTPUT_CALL.test(message.tool.name) ? `${message.tool.name} did not finish` : undefined - const call: DisplayMessage = { + let call: ToolDisplayMessage = { role: 'tool', tool_call_id: message.id, // The card's header is the row's text, which the server only words once the @@ -270,6 +334,10 @@ export function toDisplayMessages( isLoading: message.pending && message.tool?.status === 'running', jobId: message.jobId } + if (request && i > latestUser && call.isLoading && holds(request, toolName)) { + call = requestCard(request, toolName, call) + requestShown = true + } // The tool card has no thinking section: the thinking that led to the call reads // as a card of its own, just before it. return message.reasoning @@ -308,6 +376,25 @@ export function toDisplayMessages( } } }) + if (!request || requestShown) return shown + // No streamed card to put it on: the agent does not stream, or the page was opened after + // the call was made. + const calls = + request.type === 'question' + ? [{ function_name: 'ask_question', arguments: undefined }] + : request.calls + return [ + ...shown, + ...calls.map((call, n) => + requestCard(request, call.function_name, { + role: 'tool', + tool_call_id: `${request.call_id}:${n}`, + content: '', + toolName: call.function_name, + parameters: call.arguments + }) + ) + ] } /** The two paced lanes of one streaming message, and how much of each has been fed in. */ @@ -450,9 +537,30 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender<ComposerAttac displayMessages = $derived.by(() => toDisplayMessages(this.#state.messages, isBusy(this.#state.status), this.#stoppedTurns, { workspace: this.#options.workspace?.(), - revealed: (message) => this.#revealed[message.id] + revealed: (message) => this.#revealed[message.id], + request: this.#agentRequest?.action }) ) + + /** The request answered here, by agent job and call id: its cards drop their buttons until + * the flow's status catches up. */ + #answeredRequest = $state<string | undefined>(undefined) + get #agentRequest(): PendingAgentRequest | undefined { + const request = this.#options.agentRequest?.() + return request && requestKey(request) === this.#answeredRequest ? undefined : request + } + + #answer(approved: boolean, answer?: string | string[]): boolean { + const request = this.#agentRequest + const workspace = this.#options.workspace?.() + if (!request || !workspace) return false + this.#answeredRequest = requestKey(request) + answerAgentRequest(workspace, request, approved, answer).catch((e: any) => { + this.#answeredRequest = undefined + sendUserToast(e?.body ?? e?.message ?? 'Failed to answer the request', true) + }) + return true + } /** The user message of each turn stopped in this view, by message id and, once the chat has * read its row, row id: a reopened conversation or an older page rebuilds messages from rows, * so a turn left before its row was read is not recognised there. Only this session knows; @@ -698,8 +806,16 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender<ComposerAttac void this.#turns.send(composerDraft(message.content), replayInputs) } restartGeneration = () => {} - handleUserQuestionAnswer = () => false - handleToolConfirmation = () => {} + handleUserQuestionAnswer = (_toolId: string, choices: string[]) => + this.#answer( + true, + this.#agentRequest?.action.type === 'question' && this.#agentRequest.action.multi_select + ? choices + : choices[0] + ) + handleToolConfirmation = (_toolId: string, confirmed: boolean) => { + this.#answer(confirmed) + } hasPendingRunForm = false isRunFormPending = () => false diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts index 1c1a62dab8..617f3c7ffe 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.test.ts @@ -177,6 +177,51 @@ describe('toDisplayMessages', () => { expect(notCitations).toMatchObject({ toolName: 'websearch', result: { hits: 3 } }) }) + it('puts a pending request on the card of the call it holds, or adds one when none streamed', () => { + const approval = { + type: 'approval' as const, + call_id: 'c1', + calls: [{ function_name: 'delete_database', arguments: { name: 'staging' } }], + expires_at: '2026-09-16T10:30:00Z' + } + const turn = [ + message({ role: 'user', content: 'delete staging' }), + message({ role: 'tool', pending: true, tool: { name: 'list_databases', status: 'success' } }), + message({ role: 'tool', pending: true, tool: { name: 'delete_database', status: 'running' } }) + ] + const held = toDisplayMessages(turn, true, new Set(), { request: approval }) + expect(held).toHaveLength(3) + expect(held[1]).not.toHaveProperty('needsConfirmation') + expect(held[2]).toMatchObject({ + needsConfirmation: true, + isLoading: true, + parameters: { name: 'staging' } + }) + + const twice = { + ...approval, + calls: [...approval.calls, { function_name: 'delete_database', arguments: { name: 'prod' } }] + } + const added = toDisplayMessages(turn.slice(0, 1), true, new Set(), { request: twice }) + expect(added.slice(1).map((m) => (m as { parameters?: unknown }).parameters)).toEqual([ + { name: 'staging' }, + { name: 'prod' } + ]) + + const question = { + type: 'question' as const, + call_id: 'c2', + question: 'Which one?', + expires_at: '2026-09-16T10:30:00Z' + } + const unstreamed = toDisplayMessages(turn.slice(0, 1), true, new Set(), { request: question }) + expect(unstreamed[1]).toMatchObject({ + role: 'tool', + isLoading: true, + userQuestion: { question: 'Which one?', choices: [] } + }) + }) + it('shows a call the turn finished without as an error, not as still running', () => { const display = toDisplayMessages([ message({ role: 'user', content: 'hi' }), diff --git a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts index 030897a63b..955d94bf52 100644 --- a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts +++ b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts @@ -32,6 +32,7 @@ import { createAiDecisionTool, createMcpTool, createWebsearchTool, + createQuestionTool, newAiDecisionInputTransforms, newFlowModuleAgentTool, SPECIAL_TOOL_KINDS, @@ -587,6 +588,9 @@ export async function insertNewModuleAtIndex( const websearchTool = createWebsearchTool(module.id) ;(modules as AgentTool[]).splice(index, 0, websearchTool) return modules as AgentTool[] + } else if (toolKind === 'questionTool') { + ;(modules as AgentTool[]).splice(index, 0, createQuestionTool(module.id)) + return modules as AgentTool[] } else if (toolKind === 'aiAgentTool') { // Create AI Agent tool (nested agent) const aiAgentTool = createAiAgentTool(module.id) diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index b4228ea0a3..5bb48febd2 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -243,12 +243,22 @@ }} /> <TopLevelNode - label="Web Search" + label="Web search" onSelect={() => { dispatch('pickWebsearchTool') dispatch('close') }} /> + <!-- A nested agent cannot park, so the worker drops it from its roster. --> + {#if !nestedAgent} + <TopLevelNode + label="Ask a question" + onSelect={() => { + dispatch('pickQuestionTool') + dispatch('close') + }} + /> + {/if} <TopLevelNode label="AI Agent" chevron={false} diff --git a/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte b/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte index a8f3e040c4..69ce0e4848 100644 --- a/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte +++ b/frontend/src/lib/components/flows/pickers/TopLevelNode.svelte @@ -11,6 +11,7 @@ Repeat, Scale, Square, + MessageCircleQuestion, Zap } from 'lucide-svelte' import type { ComponentType } from 'svelte' @@ -54,7 +55,8 @@ 'Claude Code': { icon: BotIcon, iconClass: 'text-ai' }, Pi: { icon: BotIcon, iconClass: 'text-ai' }, MCP: { icon: Plug }, - 'Web Search': { icon: Globe } + 'Web search': { icon: Globe }, + 'Ask a question': { icon: MessageCircleQuestion } } const config = $derived(iconMap[label]) diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 98a03f8c0f..8fc8e664f0 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -32,6 +32,7 @@ export type InsertKind = | 'aidecision' | 'mcpTool' | 'websearchTool' + | 'questionTool' | 'aiAgentTool' | 'aiDecisionTool' diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte index a8880334ca..4f91b87a95 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte @@ -1,5 +1,10 @@ <script module lang="ts"> - import { getToolNameError, type AgentTool } from '$lib/components/flows/agentToolUtils' + import { + BUILTIN_TOOLS, + builtinToolType, + getToolNameError, + type AgentTool + } from '$lib/components/flows/agentToolUtils' export const AI_TOOL_BASE_OFFSET = 5 export const AI_TOOL_ROW_OFFSET = 30 @@ -9,6 +14,8 @@ export const AI_MCP_TOOL_CALL_PREFIX = '_wm_ai_mcp_tool_call' export const AI_TOOL_MESSAGE_PREFIX = '_wm_ai_agent_message' export const AI_WEBSEARCH_PREFIX = '_wm_ai_websearch' + export const AI_APPROVAL_PREFIX = '_wm_ai_approval' + export const AI_QUESTION_PREFIX = '_wm_ai_question' const ROW_WIDTH = 275 const NEW_TOOL_NODE_WIDTH = 50 @@ -48,6 +55,8 @@ return action.module_id === tool.moduleId case 'web_search': return tool.type === 'websearch' + case 'question': + return tool.type === 'question' case 'mcp_tool_call': // One MCP node stands for a whole server and many function names, so the server path // is the only join that holds. The worker strips `$res:` before building the action, @@ -56,6 +65,8 @@ tool.type === 'mcp' && bareResourcePath(action.resource_path) === bareResourcePath(tool.resourcePath) ) + // The calls it holds are decorated by their own actions once they run. + case 'approval': case 'message': return false } @@ -76,6 +87,10 @@ return AI_MCP_TOOL_CALL_PREFIX + '-' + agentModuleId + '-' + idx case 'web_search': return AI_WEBSEARCH_PREFIX + '-' + agentModuleId + '-' + idx + case 'approval': + return AI_APPROVAL_PREFIX + '-' + agentModuleId + '-' + idx + case 'question': + return AI_QUESTION_PREFIX + '-' + agentModuleId + '-' + idx case 'message': return getToolCallId(idx, agentModuleId) } @@ -156,15 +171,13 @@ stateType?: GraphModuleState['type'] resourcePath?: string }[] = sourceTools.map((t, idx) => { - // Handle FlowModule, MCP, and Websearch tools + // Handle FlowModule, MCP, and built-in tools const toolType = - t.value.tool_type === 'mcp' - ? 'mcp' - : t.value.tool_type === 'websearch' - ? 'websearch' - : t.value.tool_type === 'flowmodule' - ? t.value.type - : undefined + t.value.tool_type === 'mcp' || builtinToolType(t) + ? t.value.tool_type + : t.value.tool_type === 'flowmodule' + ? t.value.type + : undefined return { id: t.id, name: t.summary ?? '', @@ -183,7 +196,12 @@ if (a.type === 'tool_call' || a.type === 'mcp_tool_call') { return { id, name: a.function_name } } else if (a.type === 'web_search') { - return { id, name: 'Web Search', type: 'websearch' } + return { id, name: 'Web search', type: 'websearch' } + } else if (a.type === 'approval') { + const names = a.calls.map((call) => call.function_name).join(', ') + return { id, name: `Approval: ${names}`, type: 'approval' } + } else if (a.type === 'question') { + return { id, name: BUILTIN_TOOLS.question.label, type: 'question' } } else { return { id, name: 'Message' } } @@ -304,7 +322,18 @@ NewAiToolN, NodeLayout } from '../../graphBuilder.svelte' - import { Bot, Globe, MessageCircle, Play, Plug, Scale, Wrench, X } from 'lucide-svelte' + import { + Bot, + Globe, + MessageCircle, + MessageCircleQuestion, + Play, + Plug, + Scale, + UserCheck, + Wrench, + X + } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' import type { Edge, Node } from '@xyflow/svelte' @@ -403,6 +432,10 @@ <Play size={16} class="ml-1 shrink-0" /> {:else if data.type === 'websearch'} <Globe size={16} class="ml-1 shrink-0" /> + {:else if data.type === 'approval'} + <UserCheck size={16} class="ml-1 shrink-0" /> + {:else if data.type === 'question'} + <MessageCircleQuestion size={16} class="ml-1 shrink-0" /> {:else if data.type === 'mcp'} <Plug size={16} class="ml-1 shrink-0" /> {:else if data.type === 'aiagent'} diff --git a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte index 53679a0de1..0234c09beb 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte @@ -10,6 +10,7 @@ import { getContext } from 'svelte' import { getGraphContext } from '../../graphContext' import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' + import { waitingRequest } from '$lib/components/agentRequest' interface Props { data: ModuleN['data'] @@ -22,9 +23,13 @@ const flowRunStatus = getFlowRunStatusContext() let state = $derived.by(() => { - return data.testModuleState + const state = data.testModuleState ? (jobToGraphModuleState(data.testModuleState) ?? flowRunStatus?.getModuleState(data.id)) : flowRunStatus?.getModuleState(data.id) + // An agent waiting on a person reads like an approval step, though its job is running. + return state?.type === 'InProgress' && waitingRequest(state) + ? { ...state, type: 'WaitingForEvents' as const } + : state }) // A `message` is the one action with no tool node of its own, so the annotation is the only @@ -33,6 +38,8 @@ { type: 'tool_call', singular: 'tool call', plural: 'tool calls' }, { type: 'mcp_tool_call', singular: 'MCP call', plural: 'MCP calls' }, { type: 'web_search', singular: 'web search', plural: 'web searches' }, + { type: 'approval', singular: 'approval request', plural: 'approval requests' }, + { type: 'question', singular: 'question', plural: 'questions' }, { type: 'message', singular: 'message', plural: 'messages' } ] as const diff --git a/frontend/src/lib/components/graph/renderers/nodes/NewAIToolNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/NewAIToolNode.svelte index 7c088c119a..3cfa3f64f9 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/NewAIToolNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/NewAIToolNode.svelte @@ -106,6 +106,14 @@ }) close() }} + on:pickQuestionTool={() => { + data.eventHandlers.insert({ + index: -1, + agentId: data.agentModuleId, + kind: 'questionTool' + }) + close() + }} on:pickAiAgentTool={(e) => { data.eventHandlers.insert({ index: -1, diff --git a/frontend/src/lib/components/runs/FlowExecutionStatus.svelte b/frontend/src/lib/components/runs/FlowExecutionStatus.svelte index 67b6520ffb..d3bed5d562 100644 --- a/frontend/src/lib/components/runs/FlowExecutionStatus.svelte +++ b/frontend/src/lib/components/runs/FlowExecutionStatus.svelte @@ -1,5 +1,7 @@ <script lang="ts"> import FlowStatusWaitingForEvents from '../FlowStatusWaitingForEvents.svelte' + import AgentApprovalRequest from '../AgentApprovalRequest.svelte' + import { pendingAgentApproval } from '../agentRequest' import type { FlowStatusModule, Job } from '$lib/gen' import { type StateStore } from '$lib/utils' import { fade, slide } from 'svelte/transition' @@ -18,7 +20,9 @@ ) const isSuspended = $derived(suspendStatus.val && Object.keys(suspendStatus.val).length > 0) - const anyStatusActive = $derived(isWaitingForEvents || isSuspended) + const pendingApproval = $derived(pendingAgentApproval(job)) + + const anyStatusActive = $derived(isWaitingForEvents || isSuspended || !!pendingApproval) </script> {#if job && anyStatusActive} @@ -28,6 +32,15 @@ > {#if isWaitingForEvents} <FlowStatusWaitingForEvents workspaceId={job.workspace_id} {job} {isOwner} /> + {:else if pendingApproval} + {#key pendingApproval.action.call_id} + <AgentApprovalRequest + workspaceId={job.workspace_id ?? ''} + agentJobId={pendingApproval.agentJobId} + request={pendingApproval.action} + pending + /> + {/key} {:else if isSuspended} <div class="flex gap-2 flex-col" in:fade={{ duration: 150 }}> {#each Object.values(suspendStatus.val) as suspendCount (suspendCount.job.id)} diff --git a/frontend/src/lib/components/runs/JobStatusIcon.svelte b/frontend/src/lib/components/runs/JobStatusIcon.svelte index 358ab1edeb..c37791310e 100644 --- a/frontend/src/lib/components/runs/JobStatusIcon.svelte +++ b/frontend/src/lib/components/runs/JobStatusIcon.svelte @@ -13,6 +13,7 @@ X } from 'lucide-svelte' import type { Job } from '$lib/gen' + import { isSuspended } from '../agentRequest' interface Props { job: Job @@ -60,7 +61,7 @@ <Badge color="red" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'}> <X size={14} /> </Badge> - {:else if 'running' in job && job.running && job.suspend} + {:else if 'running' in job && job.running && isSuspended(job)} <Badge color="violet" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'} title="Suspended"> <Hourglass size={14} /> </Badge> diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 91a2d4b79e..1d3ee7db95 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1008,7 +1008,7 @@ components: 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 or websearch tool it is a plain label. Put the human-readable explanation in 'description'." + 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. @@ -1018,6 +1018,9 @@ components: 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: @@ -1030,12 +1033,14 @@ components: - $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 @@ -1061,6 +1066,20 @@ components: 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 @@ -1494,6 +1513,57 @@ components: 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: diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index b6ba8de664..4ddf084d34 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -68,7 +68,10 @@ value: An `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped object with an extra `value.tool_type`: `flowmodule` for a script/flow tool, `mcp` for an MCP server -tool, `websearch` for web search. +tool, `websearch` for web search, `question` for a built-in `ask_question` tool (the agent stops, +without holding a worker, until someone answers from the run page or the chat; optional +`value.timeout` in seconds, default 1800). Set `requires_approval: true` on a `flowmodule` or `mcp` +tool to have a person approve or reject each of its calls before it runs. ```json { @@ -164,9 +167,9 @@ needs becomes unreachable. ### Tool Naming Rules -These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's -`summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP server's own tool -names, so neither is name-checked at all — leave those summaries as they are. +These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` or `question` +tool's `summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP +server's own tool names, so none is name-checked at all — leave those summaries as they are. - A flowmodule tool's `summary` is the **name the agent calls it by**, not a human label. Put the human-readable explanation in `description` @@ -610,4 +613,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 or websearch 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."},"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"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"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"]},"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":["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 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 diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index e8ccf219d6..da2c4922c7 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -97,7 +97,10 @@ value: An \`aiagent\` module runs an LLM that can call tools. Each entry of \`value.tools\` is a module-shaped object with an extra \`value.tool_type\`: \`flowmodule\` for a script/flow tool, \`mcp\` for an MCP server -tool, \`websearch\` for web search. +tool, \`websearch\` for web search, \`question\` for a built-in \`ask_question\` tool (the agent stops, +without holding a worker, until someone answers from the run page or the chat; optional +\`value.timeout\` in seconds, default 1800). Set \`requires_approval: true\` on a \`flowmodule\` or \`mcp\` +tool to have a person approve or reject each of its calls before it runs. \`\`\`json { @@ -193,9 +196,9 @@ needs becomes unreachable. ### Tool Naming Rules -These rules cover \`flowmodule\` tools, the ones the agent calls by name. A \`websearch\` tool's -\`summary\` is a plain label (\`Web Search\`), and an \`mcp\` tool exposes the MCP server's own tool -names, so neither is name-checked at all — leave those summaries as they are. +These rules cover \`flowmodule\` tools, the ones the agent calls by name. A \`websearch\` or \`question\` +tool's \`summary\` is a plain label (\`Web Search\`), and an \`mcp\` tool exposes the MCP +server's own tool names, so none is name-checked at all — leave those summaries as they are. - A flowmodule tool's \`summary\` is the **name the agent calls it by**, not a human label. Put the human-readable explanation in \`description\` @@ -3747,7 +3750,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 or websearch 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."},"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"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"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"]},"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":["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 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"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 906f416184..8308f33c6b 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -155,7 +155,10 @@ value: An `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped object with an extra `value.tool_type`: `flowmodule` for a script/flow tool, `mcp` for an MCP server -tool, `websearch` for web search. +tool, `websearch` for web search, `question` for a built-in `ask_question` tool (the agent stops, +without holding a worker, until someone answers from the run page or the chat; optional +`value.timeout` in seconds, default 1800). Set `requires_approval: true` on a `flowmodule` or `mcp` +tool to have a person approve or reject each of its calls before it runs. ```json { @@ -251,9 +254,9 @@ needs becomes unreachable. ### Tool Naming Rules -These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's -`summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP server's own tool -names, so neither is name-checked at all — leave those summaries as they are. +These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` or `question` +tool's `summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP +server's own tool names, so none is name-checked at all — leave those summaries as they are. - A flowmodule tool's `summary` is the **name the agent calls it by**, not a human label. Put the human-readable explanation in `description` @@ -698,4 +701,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 or websearch 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."},"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"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"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"]},"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":["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 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"]}} diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index 80a83e948a..bc5542145e 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -68,7 +68,10 @@ value: An `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped object with an extra `value.tool_type`: `flowmodule` for a script/flow tool, `mcp` for an MCP server -tool, `websearch` for web search. +tool, `websearch` for web search, `question` for a built-in `ask_question` tool (the agent stops, +without holding a worker, until someone answers from the run page or the chat; optional +`value.timeout` in seconds, default 1800). Set `requires_approval: true` on a `flowmodule` or `mcp` +tool to have a person approve or reject each of its calls before it runs. ```json { @@ -164,9 +167,9 @@ needs becomes unreachable. ### Tool Naming Rules -These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's -`summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP server's own tool -names, so neither is name-checked at all — leave those summaries as they are. +These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` or `question` +tool's `summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP +server's own tool names, so none is name-checked at all — leave those summaries as they are. - A flowmodule tool's `summary` is the **name the agent calls it by**, not a human label. Put the human-readable explanation in `description` From df2e0cdb9d56a9d5cb7f3d64ed6ff2a621d0cca2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel <ruben@windmill.dev> Date: Mon, 26 Oct 2026 22:38:54 +0100 Subject: [PATCH 05/14] fix: show the protection-rule bypass toggle inline (#12034) * fix: show the protection-rule bypass toggle inline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: make the bypass toggle small and red Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .../lib/components/NoDirectDeployAlert.svelte | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/NoDirectDeployAlert.svelte b/frontend/src/lib/components/NoDirectDeployAlert.svelte index 1e1256241e..eee9437e65 100644 --- a/frontend/src/lib/components/NoDirectDeployAlert.svelte +++ b/frontend/src/lib/components/NoDirectDeployAlert.svelte @@ -1,8 +1,8 @@ <script lang="ts"> /** - * Badge for a workspace locked against direct edits, with the rule names, the route into the dev - * workspace and the admin bypass in its popover. Renders nothing for operators, who have no edit - * affordance for it to explain. + * Badge for a workspace locked against direct edits, with the rule names and the route into the + * dev workspace in its popover, and the admin bypass toggle beside it. Renders nothing for + * operators, who have no edit affordance for it to explain. */ import { userWorkspaces, workspaceStore } from '$lib/stores' import { resource } from 'runed' @@ -106,7 +106,7 @@ </script> {#if ($roleCaps.code || $roleCaps.flows !== 'none' || $roleCaps.apps !== 'none') && activeDeployRulesets.length > 0} - <div class="my-2"> + <div class="my-2 flex flex-wrap items-center gap-3"> <Popover placement="bottom-start" class={triggerClass} @@ -152,15 +152,17 @@ {editAdvice} </p> {/if} - {#if canBypass && !sessionReadOnly} - <Toggle - size="xs" - bind:checked={overrideChecked} - options={{ right: 'Bypass restriction' }} - /> - {/if} </div> {/snippet} </Popover> + <!-- Outside the popover: the badge reads as plain text, so a bypass behind it goes unfound. --> + {#if canBypass && !sessionReadOnly} + <Toggle + size="2xs" + color="red" + bind:checked={overrideChecked} + options={{ right: 'Bypass restriction' }} + /> + {/if} </div> {/if} From e0da5605163ce5157e9b605667c5e5fc85894db9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel <ruben@windmill.dev> Date: Mon, 26 Oct 2026 23:02:27 +0100 Subject: [PATCH 06/14] feat: build MCP OAuth urls from the api base url, narrowable per surface (#12010) * feat: configurable public base url for the MCP gateway Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep mcpTokenUrlDisabled's doc comment on its function Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: build MCP OAuth urls from the api base url, narrowable per surface Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: save and discard companion setting keys in the per-category settings flow Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- backend/src/main.rs | 19 +++-- backend/src/monitor.rs | 41 ++++++++++- backend/windmill-api-settings/src/lib.rs | 8 ++- backend/windmill-api/openapi.yaml | 3 +- backend/windmill-api/src/mcp/core.rs | 5 +- backend/windmill-api/src/mcp/oauth_server.rs | 52 +++++++++++--- .../windmill-common/src/global_settings.rs | 70 ++++++++++++++++++- .../windmill-common/src/instance_config.rs | 11 ++- frontend/src/lib/apiBaseUrl.svelte.ts | 24 +++++-- frontend/src/lib/components/CliHelpBox.svelte | 2 +- .../src/lib/components/InstanceSetting.svelte | 7 ++ .../lib/components/InstanceSettings.svelte | 35 +++++++--- frontend/src/lib/components/JobArgs.svelte | 2 +- .../src/lib/components/WorkerGroup.svelte | 2 +- .../flows/content/AgentUsePanel.svelte | 2 +- .../components/git_sync/GitPushPreview.svelte | 2 +- .../components/home/HomeConnectDrawer.svelte | 2 +- .../src/lib/components/instanceSettings.ts | 10 ++- .../instanceSettings/ApiBaseUrlSetting.svelte | 52 ++++++++++++++ .../components/settings/CreateToken.svelte | 8 +-- .../azure/AzureTriggerEditorInner.svelte | 2 +- .../gcp/GcpTriggerEditorConfigSection.svelte | 2 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 2 +- .../triggers/http/OpenAPISpecGenerator.svelte | 6 +- .../src/lib/components/triggers/http/utils.ts | 2 +- .../webhook/WebhooksConfigSection.svelte | 6 +- frontend/src/lib/utils_deployable.ts | 6 +- .../svix/create-webhook/+page@(root).svelte | 4 +- 28 files changed, 320 insertions(+), 67 deletions(-) create mode 100644 frontend/src/lib/components/instanceSettings/ApiBaseUrlSetting.svelte diff --git a/backend/src/main.rs b/backend/src/main.rs index 1035a0549f..7cdce97e7a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -38,8 +38,9 @@ use windmill_ai::ai_cache::bump_instance_ai_config_revision; use windmill_common::{ agent_workers::AgentConfig, global_settings::{ - AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, - BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, BUN_INSTALL_MIN_RELEASE_AGE_SETTING, + AI_CONFIG_SETTING, API_BASE_URL_APPLIES_TO_SETTING, API_BASE_URL_SETTING, + APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING, + BUNFIG_INSTALL_SCOPES_SETTING, BUN_INSTALL_MIN_RELEASE_AGE_SETTING, CONCURRENCY_KEY_MAX_QUEUED_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, @@ -129,10 +130,10 @@ use windmill_worker::{ }; use crate::monitor::{ - initial_load, load_concurrency_key_max_queued, load_dependency_job_tag, - load_dev_workspace_isolation, load_dev_workspace_isolation_skip_non_dev_forks, - load_dev_workspace_tag_suffix, load_disable_password_login, - load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, + initial_load, load_api_base_url, load_api_base_url_applies_to, load_concurrency_key_max_queued, + load_dependency_job_tag, load_dev_workspace_isolation, + load_dev_workspace_isolation_skip_non_dev_forks, load_dev_workspace_tag_suffix, + load_disable_password_login, load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_mcp_disable_token_query_param, load_metrics_debug_enabled, load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides, load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs, @@ -2051,6 +2052,12 @@ async fn process_notify_event( MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => { load_mcp_disable_token_query_param(db).await; } + API_BASE_URL_SETTING => { + load_api_base_url(db).await; + } + API_BASE_URL_APPLIES_TO_SETTING => { + load_api_base_url_applies_to(db).await; + } EXPOSE_METRICS_SETTING => { tracing::info!("Metrics setting changed, restarting"); spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index a27f9d2055..8ec3c33bb0 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -54,7 +54,8 @@ use windmill_common::{ error, flow_status::{FlowStatus, FlowStatusModule}, global_settings::{ - get_or_create_jwt_secret, load_value_from_global_settings, + get_or_create_jwt_secret, load_value_from_global_settings, API_BASE_URL, + API_BASE_URL_APPLIES_TO_MCP, API_BASE_URL_APPLIES_TO_SETTING, API_BASE_URL_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, BUN_INSTALL_MIN_RELEASE_AGE_SETTING, CONCURRENCY_KEY_MAX_QUEUED_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, @@ -310,6 +311,12 @@ pub async fn initial_load( false, |v| async move { apply_mcp_disable_token_query_param(v) }, ); + pass.setting(API_BASE_URL_SETTING, false, |v| async move { + apply_api_base_url(v) + }); + pass.setting(API_BASE_URL_APPLIES_TO_SETTING, false, |v| async move { + apply_api_base_url_applies_to(v) + }); pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move { apply_tag_per_workspace_enabled(v) }); @@ -1739,6 +1746,38 @@ pub fn apply_mcp_disable_token_query_param(value: Option<serde_json::Value>) { }; } +pub async fn load_api_base_url(db: &DB) { + match load_value_from_global_settings(db, API_BASE_URL_SETTING).await { + Ok(v) => apply_api_base_url(v), + Err(e) => tracing::error!("Error loading api_base_url setting: {e:#}"), + }; +} + +pub fn apply_api_base_url(value: Option<serde_json::Value>) { + let url = match value { + Some(serde_json::Value::String(s)) => s.trim().trim_end_matches('/').to_string(), + None => String::new(), + _ => return, + }; + API_BASE_URL.store(Arc::new(url)); +} + +pub async fn load_api_base_url_applies_to(db: &DB) { + match load_value_from_global_settings(db, API_BASE_URL_APPLIES_TO_SETTING).await { + Ok(v) => apply_api_base_url_applies_to(v), + Err(e) => tracing::error!("Error loading api_base_url_applies_to setting: {e:#}"), + }; +} + +pub fn apply_api_base_url_applies_to(value: Option<serde_json::Value>) { + let mcp = match value { + Some(serde_json::Value::Array(items)) => items.iter().any(|i| i.as_str() == Some("mcp")), + None => true, + _ => return, + }; + API_BASE_URL_APPLIES_TO_MCP.store(mcp, Ordering::Relaxed); +} + struct LogFile { file_path: String, hostname: String, diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 2dbb7d427c..bfbc7d135a 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -63,8 +63,8 @@ use windmill_common::{ error::{self, pg_error_message, JsonResult, Result}, get_database_url, global_settings::{ - ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, API_BASE_URL_SETTING, - APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, + ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, API_BASE_URL_APPLIES_TO_SETTING, + API_BASE_URL_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXTERNAL_INSTANCE_PG_SETTING, GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, @@ -1267,6 +1267,10 @@ async fn run_setting_pre_write_hook( } } } + API_BASE_URL_APPLIES_TO_SETTING if !value.is_null() => { + windmill_common::global_settings::validate_api_base_url_applies_to(value) + .map_err(|e| error::Error::BadRequest(format!("{key}: {e}")))?; + } MAX_TOKEN_EXPIRATION_DAYS_SETTING => { windmill_common::global_settings::parse_max_token_expiration_days(Some(value)) .map_err(|e| { diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 9e26d400cd..d016a61976 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1928,7 +1928,7 @@ paths: - setting responses: "200": - description: the instance_banner, accent_color and api_base_url settings + description: the instance_banner, accent_color, api_base_url and api_base_url_applies_to settings content: application/json: schema: @@ -1937,6 +1937,7 @@ paths: instance_banner: {} accent_color: {} api_base_url: {} + api_base_url_applies_to: {} /settings_u/ruff_config: get: diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 52a6a32014..02219e3905 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -686,7 +686,6 @@ pub async fn add_www_authenticate_header_gateway( next: Next, ) -> Response { use axum::http::StatusCode; - use windmill_common::BASE_URL; let endpoint = request .extensions() @@ -696,8 +695,8 @@ pub async fn add_www_authenticate_header_gateway( let response = next.run(request).await; if response.status() == StatusCode::UNAUTHORIZED { - let base_url = BASE_URL.load(); - let www_authenticate = www_authenticate_value(&base_url, endpoint); + let www_authenticate = + www_authenticate_value(&windmill_common::global_settings::mcp_base_url(), endpoint); let (mut parts, body) = response.into_parts(); parts.headers.insert( diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs index 677875da96..ce3e37caeb 100644 --- a/backend/windmill-api/src/mcp/oauth_server.rs +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -12,6 +12,7 @@ use sqlx::FromRow; use windmill_common::{ auth::{hash_token, TOKEN_PREFIX_LEN}, error::{Error, Result}, + global_settings::mcp_base_url, min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH, utils::rd_string, BASE_URL, DB, @@ -247,12 +248,19 @@ fn supported_scopes() -> Vec<String> { } /// GET /.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server -fn build_oauth_metadata(oauth_prefix: &str, base_url: &str) -> AuthorizationMetadata { +/// +/// `client_base_url` serves what the MCP client calls itself, `base_url` the authorization +/// endpoint the user's browser opens. +fn build_oauth_metadata( + oauth_prefix: &str, + base_url: &str, + client_base_url: &str, +) -> AuthorizationMetadata { AuthorizationMetadata { - issuer: format!("{}{}", base_url, oauth_prefix), + issuer: format!("{}{}", client_base_url, oauth_prefix), authorization_endpoint: format!("{}{}/authorize", base_url, oauth_prefix), - token_endpoint: format!("{}{}/token", base_url, oauth_prefix), - registration_endpoint: Some(format!("{}/api/mcp/oauth/server/register", base_url)), + token_endpoint: format!("{}{}/token", client_base_url, oauth_prefix), + registration_endpoint: Some(format!("{}/api/mcp/oauth/server/register", client_base_url)), scopes_supported: Some(supported_scopes()), response_types_supported: Some(vec!["code".to_string()]), grant_types_supported: Some(vec![ @@ -281,7 +289,7 @@ pub async fn workspaced_oauth_metadata( ) -> Json<AuthorizationMetadata> { let base_url = BASE_URL.load(); let oauth_prefix = format!("/api/w/{}/mcp/oauth/server", workspace_id); - Json(build_oauth_metadata(&oauth_prefix, &base_url)) + Json(build_oauth_metadata(&oauth_prefix, &base_url, &base_url)) } /// GET /.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp @@ -1037,25 +1045,24 @@ pub async fn gateway_oauth_metadata() -> Json<AuthorizationMetadata> { Json(build_oauth_metadata( "/api/mcp/gateway/oauth/server", &base_url, + &mcp_base_url(), )) } pub async fn gateway_protected_resource_metadata() -> Json<ProtectedResourceMetadata> { - let base_url = BASE_URL.load(); Json(build_protected_resource_metadata( "/api/mcp/gateway", "/api/mcp/gateway/oauth/server", - &base_url, + &mcp_base_url(), )) } /// GET /.well-known/oauth-protected-resource/api/mcp pub async fn gateway_session_protected_resource_metadata() -> Json<ProtectedResourceMetadata> { - let base_url = BASE_URL.load(); Json(build_protected_resource_metadata( "/api/mcp", "/api/mcp/gateway/oauth/server", - &base_url, + &mcp_base_url(), )) } @@ -1170,7 +1177,32 @@ pub fn gateway_authed_service() -> Router { #[cfg(test)] mod tests { - use super::validate_redirect_uri; + use super::{build_oauth_metadata, validate_redirect_uri}; + + #[test] + fn authorization_endpoint_stays_on_base_url() { + let m = build_oauth_metadata( + "/api/mcp/gateway/oauth/server", + "https://private.corp", + "https://mcp.example.com", + ); + assert_eq!( + m.issuer, + "https://mcp.example.com/api/mcp/gateway/oauth/server" + ); + assert_eq!( + m.token_endpoint, + "https://mcp.example.com/api/mcp/gateway/oauth/server/token" + ); + assert_eq!( + m.registration_endpoint.as_deref(), + Some("https://mcp.example.com/api/mcp/oauth/server/register") + ); + assert_eq!( + m.authorization_endpoint, + "https://private.corp/api/mcp/gateway/oauth/server/authorize" + ); + } #[test] fn accepts_legitimate_redirect_uris() { diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index ce33a8aefe..bc1af69353 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -188,11 +188,17 @@ pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; /// from GitHub and a separate ingress fronts the API for inbound webhooks. pub const GITHUB_APP_WEBHOOK_BASE_URL_SETTING: &str = "github_app_webhook_base_url"; /// Base URL the UI shows for the endpoints external clients call (webhooks, HTTP -/// routes, push trigger endpoints, the CLI), instead of the origin the UI is browsed on. -/// For deployments that serve the API on a separate domain or behind a gateway. +/// routes, push trigger endpoints, the CLI, the MCP server), instead of the origin the UI is +/// browsed on. For deployments that serve the API on a separate domain or behind a gateway. +/// The server reads it too, through [`mcp_base_url`], for the MCP gateway's OAuth metadata. /// EE only. Readable by any authenticated user, like [`INSTANCE_BANNER_SETTING`]: /// every user is shown URLs built from it. pub const API_BASE_URL_SETTING: &str = "api_base_url"; +/// Which of [`API_BASE_URL_SURFACES`] [`API_BASE_URL_SETTING`] applies to; unset means all, +/// so a surface added later follows the API base url until an admin narrows the list. +/// Lets an instance expose a single surface (say, only MCP) on a public host. +pub const API_BASE_URL_APPLIES_TO_SETTING: &str = "api_base_url_applies_to"; +pub const API_BASE_URL_SURFACES: [&str; 4] = ["webhooks", "push_triggers", "cli", "mcp"]; /// Instance-wide announcement rendered above every page of the app (maintenance /// windows, incidents). Readable by any authenticated user, unlike most settings: /// the banner exists to be shown to everyone, so it must never hold anything the @@ -341,6 +347,7 @@ pub struct InstanceUi { pub instance_banner: Option<serde_json::Value>, pub accent_color: Option<serde_json::Value>, pub api_base_url: Option<serde_json::Value>, + pub api_base_url_applies_to: Option<serde_json::Value>, } pub async fn get_instance_ui(db: &Pool<Postgres>) -> error::Result<InstanceUi> { @@ -349,7 +356,8 @@ pub async fn get_instance_ui(db: &Pool<Postgres>) -> error::Result<InstanceUi> { &[ INSTANCE_BANNER_SETTING, ACCENT_COLOR_SETTING, - API_BASE_URL_SETTING + API_BASE_URL_SETTING, + API_BASE_URL_APPLIES_TO_SETTING ] as &[&str] ) .fetch_all(db) @@ -360,12 +368,34 @@ pub async fn get_instance_ui(db: &Pool<Postgres>) -> error::Result<InstanceUi> { INSTANCE_BANNER_SETTING => ui.instance_banner = Some(row.value), ACCENT_COLOR_SETTING => ui.accent_color = Some(row.value), API_BASE_URL_SETTING => ui.api_base_url = Some(row.value), + API_BASE_URL_APPLIES_TO_SETTING => ui.api_base_url_applies_to = Some(row.value), _ => {} } } Ok(ui) } +/// Validate an [`API_BASE_URL_APPLIES_TO_SETTING`] value: a list of +/// [`API_BASE_URL_SURFACES`]. An unknown name is refused rather than ignored, since a +/// misspelt `mcp` would silently leave the MCP server on `base_url`. +pub fn validate_api_base_url_applies_to(value: &serde_json::Value) -> Result<(), String> { + let serde_json::Value::Array(items) = value else { + return Err("must be a list of surfaces".to_string()); + }; + for item in items { + match item.as_str() { + Some(s) if API_BASE_URL_SURFACES.contains(&s) => {} + _ => { + return Err(format!( + "must only list surfaces among {}", + API_BASE_URL_SURFACES.join(", ") + )) + } + } + } + Ok(()) +} + const SHELL_ACTIVE_CHARS: [char; 16] = [ '$', '`', '\'', '"', '\\', ';', '&', '|', '<', '>', '(', ')', '{', '}', '*', '!', ]; @@ -520,12 +550,37 @@ lazy_static::lazy_static! { pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false); pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false); pub static ref MCP_DISABLE_TOKEN_QUERY_PARAM: AtomicBool = AtomicBool::new(false); + /// Empty means unset. See [`API_BASE_URL_SETTING`]. + pub static ref API_BASE_URL: arc_swap::ArcSwap<String> = + arc_swap::ArcSwap::from_pointee(String::new()); + /// Whether [`API_BASE_URL_APPLIES_TO_SETTING`] includes `mcp`. + pub static ref API_BASE_URL_APPLIES_TO_MCP: AtomicBool = AtomicBool::new(true); /// Origins HTTP routes allow cross-origin when they configure none of their /// own. Empty means unset, which keeps the historical `*`. pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap<Vec<String>> = arc_swap::ArcSwap::from_pointee(vec![]); } +/// [`API_BASE_URL_SETTING`] when it applies to MCP, else `base_url`. The MCP gateway builds every URL it hands a +/// client to call from it: its resource identifier, the authorization server it names, and +/// that server's issuer, token and registration endpoints. Not the authorization endpoint: +/// the user's browser opens it, and the consent page it redirects to is served on `base_url`. +/// +/// Enterprise only, like the UI that shows the MCP URL: an MCP client rejects metadata +/// naming a host other than the URL it was given, so the two must agree. +pub fn mcp_base_url() -> String { + #[cfg(feature = "enterprise")] + { + let configured = API_BASE_URL.load(); + if !configured.is_empty() + && API_BASE_URL_APPLIES_TO_MCP.load(std::sync::atomic::Ordering::Relaxed) + { + return configured.to_string(); + } + } + crate::BASE_URL.load().to_string() +} + /// Whether an allowlist places no restriction at all. /// /// `*` is the explicit "open on purpose" entry, and a route carrying it behaves @@ -966,6 +1021,15 @@ pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) -> mod tests { use super::*; + #[test] + fn api_base_url_applies_to_accepts_only_known_surfaces() { + use serde_json::json; + assert!(validate_api_base_url_applies_to(&json!(["mcp", "webhooks"])).is_ok()); + assert!(validate_api_base_url_applies_to(&json!([])).is_ok()); + assert!(validate_api_base_url_applies_to(&json!(["mpc"])).is_err()); + assert!(validate_api_base_url_applies_to(&json!("mcp")).is_err()); + } + // `frontend/src/lib/tokenExpiration.test.ts` holds the same table for the token form's // parser; the two must stay in step. #[test] diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 9fe43e0fe0..d89eab747d 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -291,6 +291,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub api_base_url: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub api_base_url_applies_to: Option<Vec<String>>, + #[serde(skip_serializing_if = "Option::is_none")] pub email_domain: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub hub_base_url: Option<String>, @@ -1472,7 +1474,7 @@ pub fn diff_worker_configs( } /// Declaratively replace the global settings, rejecting a -/// `github_app_webhook_base_url`, `api_base_url` or +/// `github_app_webhook_base_url`, `api_base_url`, `api_base_url_applies_to` or /// `http_route_default_allowed_origins` the API would reject. /// /// Every declarative writer (the `sync-config` CLI, the Kubernetes operator's @@ -1532,6 +1534,13 @@ pub async fn sync_global_settings_declarative( } } + let applies_to_key = crate::global_settings::API_BASE_URL_APPLIES_TO_SETTING; + match desired.get(applies_to_key) { + None | Some(serde_json::Value::Null) => {} + Some(v) => crate::global_settings::validate_api_base_url_applies_to(v) + .map_err(|e| anyhow::anyhow!("{applies_to_key}: {e}"))?, + } + let banner_key = crate::global_settings::INSTANCE_BANNER_SETTING; match desired.get(banner_key) { None | Some(serde_json::Value::Null) => {} diff --git a/frontend/src/lib/apiBaseUrl.svelte.ts b/frontend/src/lib/apiBaseUrl.svelte.ts index 242a6aa9e0..444542d687 100644 --- a/frontend/src/lib/apiBaseUrl.svelte.ts +++ b/frontend/src/lib/apiBaseUrl.svelte.ts @@ -3,10 +3,23 @@ import { instanceUi } from '$lib/instanceUi' const ui = fromStore(instanceUi) +/** + * The groups of external-client URLs the `api_base_url` setting can be narrowed to, through + * `api_base_url_applies_to`. Mirrors `API_BASE_URL_SURFACES` on the server. + */ +export const API_BASE_URL_SURFACES = [ + { value: 'webhooks', label: 'Webhooks and HTTP routes' }, + { value: 'push_triggers', label: 'Push triggers (GCP, Azure)' }, + { value: 'cli', label: 'CLI and API examples' }, + { value: 'mcp', label: 'MCP server' } +] as const + +export type ApiBaseUrlSurface = (typeof API_BASE_URL_SURFACES)[number]['value'] + /** * Base of every URL shown for an external client to call (webhooks, HTTP routes, push - * trigger endpoints, the CLI remote): the `api_base_url` instance setting, else the - * origin being browsed. Append `/api/...` to it. + * trigger endpoints, the CLI remote, the MCP server): the `api_base_url` instance setting + * when it applies to `surface`, else the origin being browsed. Append `/api/...` to it. * * Not for URLs the editor itself calls or opens (captures, previews, OAuth popups, links * to pages): those must stay on the browsing origin, where the session cookie lives. @@ -14,8 +27,11 @@ const ui = fromStore(instanceUi) * Reactive: the setting lands after page load, so read it inside a `$derived` or the * template rather than caching the result. */ -export function apiBaseUrl(): string { +export function apiBaseUrl(surface: ApiBaseUrlSurface): string { const setting = ui.current?.api_base_url const configured = typeof setting === 'string' ? setting.trim() : '' - return configured || window.location.origin + const appliesTo = ui.current?.api_base_url_applies_to + // Unset means every surface; the server reads it the same way for MCP. + const applies = !Array.isArray(appliesTo) || appliesTo.includes(surface) + return (applies && configured) || window.location.origin } diff --git a/frontend/src/lib/components/CliHelpBox.svelte b/frontend/src/lib/components/CliHelpBox.svelte index e91b039328..55d8a7a756 100644 --- a/frontend/src/lib/components/CliHelpBox.svelte +++ b/frontend/src/lib/components/CliHelpBox.svelte @@ -6,7 +6,7 @@ const operatingWorkspace = useOperatingWorkspace() - let url = $derived(`${apiBaseUrl()}/`) + let url = $derived(`${apiBaseUrl('cli')}/`) </script> <Section label="CLI quick setup"> diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index ad2ee14b99..485d558f24 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -29,6 +29,7 @@ import InstancePgSettings from './instanceSettings/InstancePgSettings.svelte' import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte' import WebhookBaseUrlSetting from './instanceSettings/WebhookBaseUrlSetting.svelte' + import ApiBaseUrlSetting from './instanceSettings/ApiBaseUrlSetting.svelte' import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte' import InstanceBannerSetting from './instanceSettings/InstanceBannerSetting.svelte' import AccentColorSetting from './instanceSettings/AccentColorSetting.svelte' @@ -885,6 +886,12 @@ <InstancePgSettings {values} {markSettingSaved} disabled={loading} /> {:else if setting.fieldType == 'github_enterprise_app'} <GhesAppSettings {values} disabled={loading || !$enterpriseLicense} /> + {:else if setting.fieldType == 'api_base_url'} + <ApiBaseUrlSetting + {values} + placeholder={setting.placeholder} + disabled={loading || !$enterpriseLicense} + /> {:else if setting.fieldType == 'webhook_base_url'} <WebhookBaseUrlSetting {values} disabled={loading || !$enterpriseLicense} /> {:else if setting.fieldType == 'ws_connectivity'} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index d60cbeda56..de470489fd 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -3,7 +3,8 @@ scimSamlSetting, settings, settingsKeys, - instanceSettingsSaved + instanceSettingsSaved, + type Setting } from './instanceSettings' import { Alert, Button, Tab, TabContent, Tabs } from '$lib/components/common' import { SettingService, SettingsService } from '$lib/gen' @@ -502,9 +503,10 @@ const categorySettings = getSettingsForCategory(category) const obj: Record<string, any> = {} for (const s of categorySettings) { - const normalized = normalizeValue(vals[s.key], s.key) - if (normalized !== undefined) { - obj[s.key] = vals[s.key] + for (const key of [s.key, ...(s.companionKeys ?? [])]) { + if (normalizeValue(vals[key], key) !== undefined) { + obj[key] = vals[key] + } } } if (category === 'Auth/OAuth/SAML') { @@ -570,11 +572,16 @@ return dirtyCategories[category] ?? false } + /** The global settings a field owns: its `key` and any companion keys it also writes. */ + function settingKeys(s: Setting): string[] { + return [s.key, ...(s.companionKeys ?? [])] + } + export function discardCategory(category: string) { const categorySettings = getSettingsForCategory(category) - for (const s of categorySettings) { - const v = initialValues[s.key] - $values[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined + for (const key of categorySettings.flatMap(settingKeys)) { + const v = initialValues[key] + $values[key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined } if (category === 'Auth/OAuth/SAML') { oauths = JSON.parse(JSON.stringify(initialOauths)) @@ -653,10 +660,18 @@ }) ) + const companionKeys = categorySettings + .filter((x) => x.storage === 'setting') + .flatMap((x) => x.companionKeys ?? []) + .filter((key) => !deepEqual(initialValues?.[key], $values?.[key])) + for (const key of companionKeys) { + await SettingService.setGlobal({ key, requestBody: { value: $values?.[key] ?? null } }) + } + // Update only the saved category's initial values - for (const s of categorySettings) { - const v = $values[s.key] - initialValues[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined + for (const key of categorySettings.flatMap(settingKeys)) { + const v = $values[key] + initialValues[key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined } instanceSettingsSaved.update((n) => n + 1) if (categorySettings.some((s) => s.key === 'base_url')) { diff --git a/frontend/src/lib/components/JobArgs.svelte b/frontend/src/lib/components/JobArgs.svelte index e0712d66c7..bf766e9b8d 100644 --- a/frontend/src/lib/components/JobArgs.svelte +++ b/frontend/src/lib/components/JobArgs.svelte @@ -247,7 +247,7 @@ ${Object.entries(displayArgs) If using the wmill client in your code, set the following env variables: <pre ><code - >BASE_URL="{apiBaseUrl()}" + >BASE_URL="{apiBaseUrl('cli')}" WM_TOKEN="{'<TOKEN>'}"</code ></pre > diff --git a/frontend/src/lib/components/WorkerGroup.svelte b/frontend/src/lib/components/WorkerGroup.svelte index 3ee0807c50..6814d687ac 100644 --- a/frontend/src/lib/components/WorkerGroup.svelte +++ b/frontend/src/lib/components/WorkerGroup.svelte @@ -1353,7 +1353,7 @@ <p class="text-xs text-tertiary">Requires a superadmin token.</p> <pre class="mt-1 p-2 bg-surface-secondary rounded text-xs overflow-x-auto whitespace-pre-wrap break-all" - >curl -X POST '{apiBaseUrl()}/api/configs/update/worker__{name}' \ + >curl -X POST '{apiBaseUrl('cli')}/api/configs/update/worker__{name}' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -d '{JSON.stringify(cleanWorkerGroupConfig(nconfig ?? {}), null, 2)}'</pre diff --git a/frontend/src/lib/components/flows/content/AgentUsePanel.svelte b/frontend/src/lib/components/flows/content/AgentUsePanel.svelte index ee5f761b69..75a1a04205 100644 --- a/frontend/src/lib/components/flows/content/AgentUsePanel.svelte +++ b/frontend/src/lib/components/flows/content/AgentUsePanel.svelte @@ -77,7 +77,7 @@ let userSettings: UserSettings | undefined = $state(undefined) let token = $state('') let target = $derived({ - api: `${apiBaseUrl()}/api/w/${workspace}`, + api: `${apiBaseUrl('webhooks')}/api/w/${workspace}`, agentPath, chat, token diff --git a/frontend/src/lib/components/git_sync/GitPushPreview.svelte b/frontend/src/lib/components/git_sync/GitPushPreview.svelte index 25207b767c..beefaaa93a 100644 --- a/frontend/src/lib/components/git_sync/GitPushPreview.svelte +++ b/frontend/src/lib/components/git_sync/GitPushPreview.svelte @@ -40,7 +40,7 @@ buttons — the setup dialog puts them in its own footer, the standalone modal i let showCliInstructions = $state(false) const cliInstructions = $derived(`# Setup (only needed if local folder not initialized yet) npm install -g windmill-cli -wmill workspace add ${$workspaceStore} ${$workspaceStore} ${apiBaseUrl()}/ +wmill workspace add ${$workspaceStore} ${$workspaceStore} ${apiBaseUrl('cli')}/ wmill init --workspace ${$workspaceStore} --repository ${gitRepoResourcePath} # Pull workspace content to git repository diff --git a/frontend/src/lib/components/home/HomeConnectDrawer.svelte b/frontend/src/lib/components/home/HomeConnectDrawer.svelte index ff451b1da4..3530da35fa 100644 --- a/frontend/src/lib/components/home/HomeConnectDrawer.svelte +++ b/frontend/src/lib/components/home/HomeConnectDrawer.svelte @@ -13,7 +13,7 @@ let selectedTab: ConnectTab = $state('cli') let openVersion = $state(0) - const origin = $derived(typeof window === 'undefined' ? '' : `${apiBaseUrl()}/`) + const origin = $derived(typeof window === 'undefined' ? '' : `${apiBaseUrl('cli')}/`) const workspaceId = $derived($workspaceStore ?? '<workspace>') const cliCommands = $derived(`npm install -g windmill-cli wmill workspace add ${workspaceId} ${workspaceId} ${origin} diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 54c59f3362..e29070f8a5 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -74,6 +74,7 @@ export interface Setting { | 'instance_pg' | 'github_enterprise_app' | 'webhook_base_url' + | 'api_base_url' | 'ws_connectivity' | 'retention_overrides' | 'instance_banner' @@ -86,6 +87,9 @@ export interface Setting { onChange: (values: Record<string, any>) => Record<string, any> checked: (values: Record<string, any>) => boolean } + /** Other global settings the field reads and writes besides `key`, so the form tracks + * their changes too. */ + companionKeys?: string[] hiddenIfNull?: boolean hiddenIfEmpty?: boolean hiddenInEe?: boolean @@ -217,6 +221,7 @@ export const settings: Record<string, Setting[]> = { label: 'The API is served on a different url than the UI', onChange(values) { values['api_base_url'] = values['api_base_url'] == null ? '' : null + if (values['api_base_url'] == null) values['api_base_url_applies_to'] = null return values }, checked: (values) => values['api_base_url'] != null @@ -225,9 +230,10 @@ export const settings: Record<string, Setting[]> = { { label: 'API base url', description: - 'Base url shown to users for the endpoints external clients call (webhooks, HTTP routes, push trigger endpoints, the CLI remote), without trailing slash. Leave empty to show the url the UI is browsed on. Set it when the API is served on a separate domain or behind an API gateway. If that url does not also serve the UI, the CLI must authenticate with a token rather than the browser login.', + "Base url shown to users for the endpoints external clients call (webhooks, HTTP routes, push trigger endpoints, the CLI remote, the MCP server), without trailing slash. Leave empty to show the url the UI is browsed on. Set it when the API is served on a separate domain or behind an API gateway, and untick what that url does not serve: those keep the url the UI is browsed on. If it does not also serve the UI, the CLI must authenticate with a token rather than the browser login. MCP clients get their OAuth endpoints on it, except sign-in, which opens on the base url in the user's browser; a host serving only MCP must forward /api/mcp/* and /.well-known/oauth-*/api/mcp/*.", key: 'api_base_url', - fieldType: 'text', + fieldType: 'api_base_url', + companionKeys: ['api_base_url_applies_to'], placeholder: 'https://api.windmill.company.com', storage: 'setting', ee_only: '', diff --git a/frontend/src/lib/components/instanceSettings/ApiBaseUrlSetting.svelte b/frontend/src/lib/components/instanceSettings/ApiBaseUrlSetting.svelte new file mode 100644 index 0000000000..e999671b27 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/ApiBaseUrlSetting.svelte @@ -0,0 +1,52 @@ +<script lang="ts"> + import TextInput from '../text_input/TextInput.svelte' + import Checkbox from '../common/checkbox/Checkbox.svelte' + import { API_BASE_URL_SURFACES, type ApiBaseUrlSurface } from '$lib/apiBaseUrl.svelte' + import type { Writable } from 'svelte/store' + + interface Props { + values: Writable<Record<string, any>> + placeholder?: string + disabled?: boolean + } + + let { values, placeholder, disabled = false }: Props = $props() + + const allSurfaces = API_BASE_URL_SURFACES.map((s) => s.value) + + // Unset means every surface, including ones added later. + let appliesTo = $derived<ApiBaseUrlSurface[]>( + Array.isArray($values['api_base_url_applies_to']) + ? $values['api_base_url_applies_to'] + : allSurfaces + ) + + function toggle(surface: ApiBaseUrlSurface, on: boolean) { + const next = allSurfaces.filter((s) => (s === surface ? on : appliesTo.includes(s))) + // Ticking every box returns to unset, so a surface added later follows the url too. + $values['api_base_url_applies_to'] = next.length === allSurfaces.length ? null : next + } +</script> + +<div class="flex flex-col gap-2"> + <TextInput + inputProps={{ type: 'text', id: 'api_base_url', placeholder, disabled }} + bind:value={$values['api_base_url']} + class="max-w-lg" + /> + <div class="flex flex-col gap-1"> + <span class="text-xs text-secondary">Applies to</span> + {#each API_BASE_URL_SURFACES as { value, label } (value)} + <label class="flex items-center gap-2 text-xs"> + <!-- The form stores an empty list as unset, which means every surface: to apply + it to none, clear the url instead. --> + <Checkbox + checked={appliesTo.includes(value)} + disabled={disabled || (appliesTo.length === 1 && appliesTo[0] === value)} + onChange={() => toggle(value, !appliesTo.includes(value))} + /> + <span>{label}</span> + </label> + {/each} + </div> +</div> diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index b8d512b799..2db9fbdf4c 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -6,6 +6,7 @@ import Toggle from '../Toggle.svelte' import { SettingService, UserService, type NewToken } from '$lib/gen' import { mcpTokenUrlDisabled } from '$lib/mcpAuth' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' import TokenDisplay from './TokenDisplay.svelte' import ScopesPicker from './ScopesPicker.svelte' import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration' @@ -255,10 +256,9 @@ $operatingWorkspace || '' ) - // On the browsing origin, not the API base url: the OAuth metadata the server - // publishes for this URL is built from `base_url`, and a client rejects a mismatch. - // One URL whatever the access: the token's scopes decide which tools it gets. - const mcpUrl = `${window.location.origin}/api/mcp` + // The server builds this URL's OAuth metadata from the same API base url, and a client + // rejects a mismatch. One URL whatever the access: the token's scopes decide which tools it gets. + const mcpUrl = $derived(`${apiBaseUrl('mcp')}/api/mcp`) // Selected scripts and flows are listed per workspace: across several, a path could be two. const selectionNeedsOneWorkspace = $derived( mcpAccess === 'selected' && (mcpAllWorkspaces || mcpWorkspaces.length !== 1) diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index 6b52a05428..d038b37f1b 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -78,7 +78,7 @@ let permissionedAs = $state<string | undefined>(undefined) let selectedPermissionedAs = $state<string | undefined>(undefined) let preservePermissionedAs = $state(false) - let base_endpoint = $derived(apiBaseUrl()) + let base_endpoint = $derived(apiBaseUrl('push_triggers')) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandler = $state(handlerDraftFromStored('error', undefined)) diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte index a33573a1d1..565bcb9a7a 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte @@ -32,7 +32,7 @@ const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) const wsId = $derived($operatingWorkspace) - const base_endpoint = $derived(`${apiBaseUrl()}/api/gcp/w/${wsId!}`) + const base_endpoint = $derived(`${apiBaseUrl('push_triggers')}/api/gcp/w/${wsId!}`) let topic_items: string[] = $state([]) let subscription_items: string[] = $state([]) diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 04052a54d6..fd01f9bb26 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -88,7 +88,7 @@ let permissionedAs = $state<string | undefined>(undefined) let selectedPermissionedAs = $state<string | undefined>(undefined) let preservePermissionedAs = $state(false) - let base_endpoint = $derived(apiBaseUrl()) + let base_endpoint = $derived(apiBaseUrl('push_triggers')) let auto_acknowledge_msg = $state(true) let ack_deadline: number | undefined = $state() let optionTabSelected: 'settings' | 'error_handler' | 'retries' = $state('error_handler') diff --git a/frontend/src/lib/components/triggers/http/OpenAPISpecGenerator.svelte b/frontend/src/lib/components/triggers/http/OpenAPISpecGenerator.svelte index 024086552c..05a5543fab 100644 --- a/frontend/src/lib/components/triggers/http/OpenAPISpecGenerator.svelte +++ b/frontend/src/lib/components/triggers/http/OpenAPISpecGenerator.svelte @@ -136,7 +136,7 @@ obj = { openapi_spec_format: lang, info, - url: apiBaseUrl(), + url: apiBaseUrl('webhooks'), http_route_filters: getHttpRouteFilters(), webhook_filters: getWebhookFilters() } @@ -155,7 +155,7 @@ requestBody: { openapi_spec_format, info, - url: apiBaseUrl(), + url: apiBaseUrl('webhooks'), http_route_filters: getHttpRouteFilters(), webhook_filters: getWebhookFilters() } @@ -225,7 +225,7 @@ {/snippet} <CopyableCodeBlock code={`token=${emptyString(token) ? '' : token}; \\ -curl -X POST "${apiBaseUrl()}/api/w/${$operatingWorkspace!}/openapi/generate" \\ +curl -X POST "${apiBaseUrl('cli')}/api/w/${$operatingWorkspace!}/openapi/generate" \\ -H "Authorization: Bearer $token" \\ -H "Content-Type: application/json" \\ -d '${JSON.stringify(obj)}'`} diff --git a/frontend/src/lib/components/triggers/http/utils.ts b/frontend/src/lib/components/triggers/http/utils.ts index cc4d366be4..8847275c9e 100644 --- a/frontend/src/lib/components/triggers/http/utils.ts +++ b/frontend/src/lib/components/triggers/http/utils.ts @@ -158,7 +158,7 @@ export function getHttpRoute( workspaced_route: boolean, workspace_id: string ) { - return `${apiBaseUrl()}/api/${route_prefix}/${ + return `${apiBaseUrl('webhooks')}/api/${route_prefix}/${ isCloudHosted() || workspaced_route ? workspace_id + '/' : '' }${route_path ?? ''}` } diff --git a/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte b/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte index 218430316a..1862935997 100644 --- a/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte +++ b/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte @@ -46,7 +46,7 @@ const operatingWorkspace = useOperatingWorkspace() const wsId = $derived($operatingWorkspace) - const WEBHOOK_BASE_URL = $derived(`${apiBaseUrl()}/api/w/${wsId}/jobs`) + const WEBHOOK_BASE_URL = $derived(`${apiBaseUrl('webhooks')}/api/w/${wsId}/jobs`) let baseWebhookUrl = $derived.by(() => { let webhookUrlPath: string @@ -223,7 +223,7 @@ async function triggerJob() { function waitForJobCompletion(UUID) { return new Promise(async (resolve, reject) => { try { - const endpoint = \`${apiBaseUrl()}/api/w/${wsId}/jobs_u/completed/get_result_maybe/\${UUID}\`; + const endpoint = \`${apiBaseUrl('webhooks')}/api/w/${wsId}/jobs_u/completed/get_result_maybe/\${UUID}\`; const checkResponse = await fetch(endpoint, { method: 'GET', headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n\t\t\t\t')} @@ -266,7 +266,7 @@ ${ ? 'echo -E $RESULT | jq' : requestType === 'async' ? ` -URL="${apiBaseUrl()}/api/w/${wsId}/jobs_u/completed/get_result_maybe/$UUID" +URL="${apiBaseUrl('webhooks')}/api/w/${wsId}/jobs_u/completed/get_result_maybe/$UUID" while true; do curl -s -H "Authorization: Bearer $TOKEN" $URL -o res.json COMPLETED=$(cat res.json | jq .completed) diff --git a/frontend/src/lib/utils_deployable.ts b/frontend/src/lib/utils_deployable.ts index e3f7fa83ed..491863b0e3 100644 --- a/frontend/src/lib/utils_deployable.ts +++ b/frontend/src/lib/utils_deployable.ts @@ -250,7 +250,9 @@ export async function getTriggersDeployData( ...gcpTrigger, delivery_config: gcpTrigger.delivery_config ?? undefined, base_endpoint: - gcpTrigger.delivery_type === 'push' ? (targetBaseUrl ?? apiBaseUrl()) : undefined, + gcpTrigger.delivery_type === 'push' + ? (targetBaseUrl ?? apiBaseUrl('push_triggers')) + : undefined, permissioned_as: onBehalfOf, preserve_permissioned_as: preservePermissionedAs } @@ -318,7 +320,7 @@ export async function getTriggersDeployData( base_endpoint: azureTrigger.azure_mode === 'namespace_pull' ? undefined - : (targetBaseUrl ?? apiBaseUrl()), + : (targetBaseUrl ?? apiBaseUrl('push_triggers')), permissioned_as: onBehalfOf, preserve_permissioned_as: preservePermissionedAs }, diff --git a/frontend/src/routes/(root)/(logged)/svix/create-webhook/+page@(root).svelte b/frontend/src/routes/(root)/(logged)/svix/create-webhook/+page@(root).svelte index 212696782b..7952e4b76d 100644 --- a/frontend/src/routes/(root)/(logged)/svix/create-webhook/+page@(root).svelte +++ b/frontend/src/routes/(root)/(logged)/svix/create-webhook/+page@(root).svelte @@ -28,11 +28,11 @@ let webhook: string switch (kind) { case 'script': - webhook = `${apiBaseUrl()}/api/w/${$workspaceStore}/jobs/run/p/${path}` + webhook = `${apiBaseUrl('webhooks')}/api/w/${$workspaceStore}/jobs/run/p/${path}` redirectSuccess(webhook, token) break case 'flow': - webhook = `${apiBaseUrl()}/api/w/${$workspaceStore}/jobs/run/f/${path}` + webhook = `${apiBaseUrl('webhooks')}/api/w/${$workspaceStore}/jobs/run/f/${path}` redirectSuccess(webhook, token) break default: From e2e2e7d7c4924fe29a912c603554f8545184d704 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:14:31 +0100 Subject: [PATCH 07/14] fix: build hub script urls and cache keys from the parsed id only (#12028) --- backend/windmill-common/src/flows.rs | 51 ++++------------- backend/windmill-common/src/scripts.rs | 79 +++++++++++--------------- backend/windmill-common/src/utils.rs | 15 +++++ 3 files changed, 58 insertions(+), 87 deletions(-) diff --git a/backend/windmill-common/src/flows.rs b/backend/windmill-common/src/flows.rs index 500549c549..1ea323386a 100644 --- a/backend/windmill-common/src/flows.rs +++ b/backend/windmill-common/src/flows.rs @@ -18,7 +18,7 @@ use crate::{ db::DB, error::{to_anyhow, Error}, scripts::ScriptHash, - utils::{http_get_from_hub, StripPath}, + utils::{http_get_from_hub, parse_hub_id, StripPath}, worker::{to_raw_value, Connection}, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION, }; @@ -33,45 +33,13 @@ struct HubFlowResponse { flow: HubFlow, } -fn extract_hub_flow_id_from_path(path: &str) -> Result<i32, Error> { - let hub_flow_path = path.strip_prefix("hub/flows/").ok_or_else(|| { - Error::BadRequest(format!( - "expected hub flow path to start with hub/flows/ (got {path})" - )) - })?; - - let flow_id = hub_flow_path - .split('/') - .next() - .filter(|segment| !segment.is_empty()) - .ok_or_else(|| { - Error::BadRequest(format!( - "expected hub flow path to include a numeric id after hub/flows/ (got {path})" - )) - })?; - - let flow_id = flow_id.parse::<i32>().map_err(|_| { - Error::BadRequest(format!( - "expected hub flow path to include a numeric id after hub/flows/ (got {path})" - )) - })?; - - if flow_id <= 0 { - return Err(Error::BadRequest(format!( - "expected hub flow path to include a positive numeric id after hub/flows/ (got {path})" - ))); - } - - Ok(flow_id) -} - pub async fn get_full_hub_flow_by_path( path: StripPath, http_client: &reqwest::Client, db: Option<&DB>, ) -> crate::error::Result<HubFlow> { let path = path.to_path(); - let flow_id = extract_hub_flow_id_from_path(&path)?; + let flow_id = parse_hub_id(&path, "hub/flows/")?; let hub_base_url = (**HUB_BASE_URL.load()).clone(); let hub_url = format!("{hub_base_url}/flows/{flow_id}/json"); @@ -472,33 +440,36 @@ mod tests { #[test] fn extract_hub_flow_id_accepts_id_only_paths() { - assert_eq!(extract_hub_flow_id_from_path("hub/flows/76").unwrap(), 76); + assert_eq!(parse_hub_id("hub/flows/76", "hub/flows/").unwrap(), 76); } #[test] fn extract_hub_flow_id_accepts_id_and_slug_paths() { assert_eq!( - extract_hub_flow_id_from_path("hub/flows/76/send-message-to-company-ai-assistant") - .unwrap(), + parse_hub_id( + "hub/flows/76/send-message-to-company-ai-assistant", + "hub/flows/" + ) + .unwrap(), 76 ); } #[test] fn extract_hub_flow_id_rejects_non_numeric_ids() { - let err = extract_hub_flow_id_from_path("hub/flows/send_message").unwrap_err(); + let err = parse_hub_id("hub/flows/send_message", "hub/flows/").unwrap_err(); assert!(matches!(err, Error::BadRequest(_))); } #[test] fn extract_hub_flow_id_rejects_missing_ids() { - let err = extract_hub_flow_id_from_path("hub/flows/").unwrap_err(); + let err = parse_hub_id("hub/flows/", "hub/flows/").unwrap_err(); assert!(matches!(err, Error::BadRequest(_))); } #[test] fn extract_hub_flow_id_rejects_zero_ids() { - let err = extract_hub_flow_id_from_path("hub/flows/0").unwrap_err(); + let err = parse_hub_id("hub/flows/0", "hub/flows/").unwrap_err(); assert!(matches!(err, Error::BadRequest(_))); } diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 87148f3f4c..1e97eae944 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -9,7 +9,7 @@ pub use windmill_types::scripts::*; use crate::{ - error::{to_anyhow, Error}, + error::to_anyhow, runnable_settings::{self}, utils::http_get_from_hub, workspace_dependencies::WorkspaceDependenciesAnnotatedRefs, @@ -22,7 +22,7 @@ use backon::ConstantBuilder; use backon::{BackoffBuilder, Retryable}; use regex::Regex; -use crate::utils::StripPath; +use crate::utils::{parse_hub_id, StripPath}; /// The tag a script's dependency job runs on, given the script's own tag; `None` leaves it to /// `push`'s default (the language tag). Every relock of a script must use this, or a relock @@ -195,17 +195,12 @@ pub async fn get_hub_script_by_path( http_client: &reqwest::Client, db: &DB, ) -> crate::error::Result<String> { - let path = path - .to_path() - .strip_prefix("hub/") - .ok_or_else(|| Error::BadRequest("Impossible to remove prefix hex".to_string()))?; - + let id = parse_hub_id(path.to_path(), "hub/")?; let hub_base_url = (**HUB_BASE_URL.load()).clone(); - // let result = http_get_from_hub( http_client, - &format!("{}/raw/{}.ts", hub_base_url, path), + &format!("{hub_base_url}/raw/{id}/script.ts"), true, None, Some(db), @@ -220,19 +215,11 @@ pub async fn get_hub_script_by_path( match result { Ok(result) => Ok(result), Err(e) => { - if hub_base_url != DEFAULT_HUB_BASE_URL - && path - .split("/") - .next() - .is_some_and(|x| x.parse::<i32>().is_ok_and(|x| x < PRIVATE_HUB_MIN_VERSION)) - { - tracing::info!( - "Not found on private hub, fallback to default hub for {}", - path - ); + if hub_base_url != DEFAULT_HUB_BASE_URL && id < PRIVATE_HUB_MIN_VERSION { + tracing::info!("Not found on private hub, fallback to default hub for {id}"); let content = http_get_from_hub( http_client, - &format!("{}/raw/{}.ts", DEFAULT_HUB_BASE_URL, path), + &format!("{DEFAULT_HUB_BASE_URL}/raw/{id}/script.ts"), true, None, Some(db), @@ -257,15 +244,8 @@ pub async fn get_full_hub_script_by_path( http_client: &reqwest::Client, db: Option<&DB>, ) -> crate::error::Result<HubScript> { - let path = path - .to_path() - .strip_prefix("hub/") - .ok_or_else(|| Error::BadRequest("Impossible to remove prefix hex".to_string()))?; - - let mut path_iterator = path.split("/"); - let version = path_iterator - .next() - .ok_or_else(|| Error::internal_err(format!("expected hub path to have version number")))?; + let path = path.to_path(); + let version = parse_hub_id(path, "hub/")?; // A cache entry that cannot be read or parsed counts as a miss rather than an error: // a truncated write leaves a file that exists but deserializes to nothing, and refetching // it is always preferable to failing the job push it was read for. @@ -288,10 +268,10 @@ pub async fn get_full_hub_script_by_path( return Ok(script); } - let script = get_full_hub_script_by_path_inner(path, http_client, db).await?; + let script = get_full_hub_script_by_path_inner(version, http_client, db).await?; if let Err(e) = crate::worker::write_file( &HUB_CACHE_DIR, - &version, + &version.to_string(), &serde_json::to_string(&script).map_err(to_anyhow)?, ) { tracing::error!("failed to write hub script {path} to cache: {e}"); @@ -302,7 +282,7 @@ pub async fn get_full_hub_script_by_path( } async fn get_full_hub_script_by_path_inner( - path: &str, + id: i32, http_client: &reqwest::Client, db: Option<&DB>, ) -> crate::error::Result<HubScript> { @@ -311,7 +291,7 @@ async fn get_full_hub_script_by_path_inner( let response = (|| async { let response = http_get_from_hub( http_client, - &format!("{}/raw2/{}", hub_base_url, path), + &format!("{hub_base_url}/raw2/{id}/script"), true, None, db, @@ -322,19 +302,12 @@ async fn get_full_hub_script_by_path_inner( match response { Ok(response) => Ok(response), Err(e) => { - if hub_base_url != DEFAULT_HUB_BASE_URL - && path.split("/").next().is_some_and(|x| { - x.parse::<i32>().is_ok_and(|x| x < PRIVATE_HUB_MIN_VERSION) - }) - { + if hub_base_url != DEFAULT_HUB_BASE_URL && id < PRIVATE_HUB_MIN_VERSION { // TODO: should only fallback to default hub if status is 404 (hub returns 500 currently) - tracing::info!( - "Not found on private hub, fallback to default hub for {}", - path - ); + tracing::info!("Not found on private hub, fallback to default hub for {id}"); http_get_from_hub( http_client, - &format!("{}/raw2/{}", DEFAULT_HUB_BASE_URL, path), + &format!("{DEFAULT_HUB_BASE_URL}/raw2/{id}/script"), true, None, db, @@ -355,9 +328,7 @@ async fn get_full_hub_script_by_path_inner( .build(), ) .notify(|err, dur| { - tracing::warn!( - "Could not get hub script at path {path}, retrying in {dur:#?}, err: {err:#?}" - ); + tracing::warn!("Could not get hub script {id}, retrying in {dur:#?}, err: {err:#?}"); }) .sleep(tokio::time::sleep) .await?; @@ -365,7 +336,7 @@ async fn get_full_hub_script_by_path_inner( let script = response .json::<HubScript>() .await - .context(format!("Decoding hub response for script at path {path}"))?; + .context(format!("Decoding hub response for script {id}"))?; Ok(script) } @@ -509,3 +480,17 @@ pub async fn deploy_relocked_version( Ok(new_hash) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hub_script_id_is_the_first_segment_only() { + let id = |p: &str| parse_hub_id(p, "hub/").ok(); + assert_eq!(id("hub/28102/windmill/git_sync"), Some(28102)); + assert_eq!(id("hub/28102/../19862/x"), Some(28102)); + assert_eq!(id("hub/../19862/x"), None); + assert_eq!(id("hub/flows/76"), None); + } +} diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 074469893d..6ae44ccf7f 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -216,6 +216,21 @@ impl StripPath { } } +/// The Hub serves scripts and flows by their numeric id alone and ignores the rest of the +/// path, so URLs and cache keys must be built from the parsed id: passing the path through +/// lets dot segments make the URL name one item while the cache key names another. +pub(crate) fn parse_hub_id(path: &str, prefix: &str) -> Result<i32> { + path.strip_prefix(prefix) + .and_then(|p| p.split('/').next()) + .and_then(|id| id.parse::<i32>().ok()) + .filter(|id| *id > 0) + .ok_or_else(|| { + Error::BadRequest(format!( + "expected a positive numeric id after {prefix} (got {path})" + )) + }) +} + /// Escape ILIKE special characters (`%`, `_`, `\`) so user input is matched /// literally. Use this when building `ILIKE '%…%'` patterns from user-supplied /// strings to prevent wildcard injection. From b813c1b35d20291e0bf2ec23add0f76de7788e27 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:15:19 +0100 Subject: [PATCH 08/14] fix: compute list row rights when drawn instead of reloading (#12023) * fix: compute list row rights when drawn instead of reloading * refactor: tick home rows by key so the selection stores the row's own entry --- .../components/common/table/FlowRow.svelte | 8 ++--- frontend/src/lib/components/home/Item.svelte | 31 ++++++++++++---- .../src/lib/components/home/ItemsList.svelte | 36 ++++++------------- .../components/home/homeSelection.dom.test.ts | 10 +++--- .../components/home/homeSelection.svelte.ts | 18 ++++++---- .../lib/components/home/homeSelection.test.ts | 2 +- .../src/lib/components/home/treeViewUtils.ts | 1 - .../components/resources/ResourcesList.svelte | 35 ++++++++---------- .../components/schedules/SchedulesList.svelte | 30 +++++++--------- .../components/triggers/TriggerList.svelte | 33 ++++++++--------- .../components/variables/VariablesList.svelte | 25 +++++-------- frontend/src/lib/operatorWriteRights.ts | 17 +++++++++ .../(root)/(logged)/folders/+page.svelte | 24 +++++-------- .../(root)/(logged)/groups/+page.svelte | 13 +++---- 14 files changed, 137 insertions(+), 146 deletions(-) diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index eb58ecbc3f..beb6b00d0f 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -8,7 +8,6 @@ import type ShareModal from '$lib/components/ShareModal.svelte' import { FlowService, type Flow } from '$lib/gen' import { roleCapabilities, userStore, userWorkspaces, workspaceStore } from '$lib/stores' - import { useEditRights } from '$lib/operatorWriteRights' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' @@ -45,8 +44,6 @@ import EditInForkButton from './EditInForkButton.svelte' import { isCloudHosted } from '$lib/cloud' - const editRights = useEditRights() - interface Props { flow: Flow & { draft_only?: boolean @@ -67,6 +64,8 @@ depth?: number menuOpen?: boolean showEditButton?: boolean + /** The acting role may author flows; an operator's row hides the authoring actions. */ + canAuthor: boolean keyboardSelected?: boolean rowSelection?: RowSelection } @@ -82,6 +81,7 @@ depth = 0, menuOpen = $bindable(false), showEditButton = $bindable(true), + canAuthor, keyboardSelected = false, rowSelection = undefined }: Props = $props() @@ -129,7 +129,7 @@ let scheduleEditor: ScheduleEditor | undefined = $state(undefined) let flowHistory: FlowHistory | undefined = $state(undefined) - let hideForOperator = $derived(!editRights.roleCanAuthor('flow')) + let hideForOperator = $derived(!canAuthor) </script> {#if menuOpen} diff --git a/frontend/src/lib/components/home/Item.svelte b/frontend/src/lib/components/home/Item.svelte index 67dac7b3dd..9e7375c866 100644 --- a/frontend/src/lib/components/home/Item.svelte +++ b/frontend/src/lib/components/home/Item.svelte @@ -13,6 +13,7 @@ import { ArrowBigUp } from 'lucide-svelte' import { userStore, workspaceStore } from '$lib/stores' import { getHomeSelection, toBulkItem } from './homeSelection.svelte' + import { getSharedEditRights } from '$lib/operatorWriteRights' import type { RowSelection } from '../common/table/rowSelection' const dispatch = createEventDispatcher() @@ -39,6 +40,20 @@ keyboardSelected = false }: Props = $props() + const editRights = getSharedEditRights() + // Worked out per row rather than when the listing loads, so the rights follow the acting + // user as it resolves and the listing never has to reload for it. + let row = $derived({ + ...item, + canWrite: + editRights.canEditItem( + item.type === 'app' && item.raw_app ? 'raw_app' : item.type, + item.path, + item.extra_perms ?? {} + ) && + (item.type === 'script' || item.workspace_id == $workspaceStore) + }) + // Read from context rather than threaded down: the tree renders items through // several nested levels that have no other reason to know about selection. const homeSelection = getHomeSelection() @@ -48,7 +63,7 @@ // so it must not enter the selection either. let bulkItem = $derived( homeSelection?.available && item.type !== 'raw_app' - ? toBulkItem(item, $userStore, $workspaceStore) + ? toBulkItem(row, $userStore, $workspaceStore) : undefined ) $effect(() => { @@ -63,7 +78,8 @@ key: bulkItem.key, selected: homeSelection.has(bulkItem.key), active: homeSelection.active, - onToggle: (e) => bulkItem && homeSelection.toggle(bulkItem, 'shiftKey' in e && e.shiftKey) + onToggle: (e) => + bulkItem && homeSelection.toggle(bulkItem.key, 'shiftKey' in e && e.shiftKey) } : undefined ) @@ -74,7 +90,7 @@ bind:deleteConfirmedCallback marked={item.marked} on:change={() => dispatch('scriptChanged')} - script={item} + script={row} errorHandlerMuted={item.ws_error_handler_muted === undefined || item.ws_error_handler_muted === null ? false @@ -94,7 +110,8 @@ bind:deleteConfirmedCallback marked={item.marked} on:change={() => dispatch('flowChanged')} - flow={item} + flow={row} + canAuthor={editRights.roleCanAuthor('flow')} errorHandlerMuted={item.ws_error_handler_muted === undefined || item.ws_error_handler_muted === null ? false @@ -113,7 +130,7 @@ bind:deleteConfirmedCallback marked={item.marked} on:change={() => dispatch('appChanged')} - app={item} + app={row} {moveDrawer} {shareModal} {deploymentDrawer} @@ -126,7 +143,7 @@ {:else if item.type == 'raw_app'} <RawAppRow marked={item.marked} - app={item} + app={row} {shareModal} {deploymentDrawer} {depth} @@ -138,7 +155,7 @@ bind:deleteConfirmedCallback marked={item.marked} on:change={() => dispatch('reload')} - agent={item} + agent={row} {shareModal} {deploymentDrawer} {depth} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 36099ac1b9..c16056347f 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -17,7 +17,7 @@ import { resource } from 'runed' import { getDraftItems } from '$lib/workspaceDrafts.svelte' import { disableHubStore, roleCapabilities, userStore, workspaceStore } from '$lib/stores' - import { useEditRights } from '$lib/operatorWriteRights' + import { shareEditRights, useEditRights } from '$lib/operatorWriteRights' import type uFuzzy from '@leeoniya/ufuzzy' import { Bot, @@ -66,10 +66,10 @@ import { tick, untrack } from 'svelte' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' import BulkActionsBar from './BulkActionsBar.svelte' - import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte' + import { bulkKey, HomeSelection, setHomeSelection } from './homeSelection.svelte' import HomeQuickAccess from './HomeQuickAccess.svelte' - const editRights = useEditRights() + const editRights = shareEditRights(useEditRights()) interface Props { subtab?: 'flow' | 'script' | 'app' | 'agent' @@ -238,7 +238,6 @@ }) type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & { - canWrite: boolean marked?: string type?: U time?: number @@ -359,22 +358,12 @@ let loadGen = 0 function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp | TableAgent { - const base = { - ...it, - canWrite: - editRights.canEditItem( - it.type === 'app' && it.raw_app ? 'raw_app' : it.type, - it.path, - (it.extra_perms ?? {}) as any - ) && - (it.type === 'script' || it.workspace_id == $workspaceStore) - } // combinedItems reads a script's time from `created_at`; the endpoint's // unified `edited_at` holds exactly that for scripts. - if (it.type === 'script') return { ...base, created_at: it.edited_at } as unknown as TableScript + if (it.type === 'script') return { ...it, created_at: it.edited_at } as unknown as TableScript // An agent row reads its resource's description, which the endpoint reports as `summary`. - if (it.type === 'agent') return { ...base, description: it.summary } as unknown as TableAgent - return base as unknown as TableFlow | TableApp + if (it.type === 'agent') return { ...it, description: it.summary } as unknown as TableAgent + return it as unknown as TableFlow | TableApp } // The merged, server-ordered, keyset-paginated source. `reset` reloads from @@ -383,7 +372,7 @@ // the existing per-kind arrays so the downstream pipeline is unchanged. async function loadRunnables(reset: boolean): Promise<void> { const ws = $workspaceStore - if (!ws || !$userStore) return + if (!ws) return // Only the very first load shows the skeleton; filter reloads keep the toolbar // and current items visible (they're replaced on arrival) so the filter control // itself doesn't flicker out when you use it. @@ -569,7 +558,7 @@ const force = opts?.force ?? false const all = opts?.all ?? false const ws = $workspaceStore - if (!ws || !$userStore) return + if (!ws) return // Track the prefix as open first — even a no-op call (re-expanding a cached node) // means it's on screen, so later reloads must refresh it. openOwners.add(owner) @@ -1309,7 +1298,7 @@ // folder paginates within itself; users past the window are reached via their owner chip. let treeGlobalHasMore = $derived(!treeLazyMode && !searching ? hasMoreServer : false) $effect(() => { - if ($userStore && $workspaceStore) { + if ($workspaceStore) { ;[archived, includeWithoutMain, sortOrder, searching, ownerFilter, itemKind] // reloadItems (not a bare loadRunnables) so an in-place change — sort, archive, // library, kind — reloads the global stream AND re-fetches the currently-open owners @@ -1340,7 +1329,7 @@ const kind = itemKind // Any term/scope change restarts search paging. searchCursor = undefined - if (term === '' || !ws || !$userStore) return + if (term === '' || !ws) return const handle = setTimeout(async () => { let res: { items: RunnableItem[]; next_cursor?: string } try { @@ -1850,10 +1839,7 @@ displayedItems[selectedIndex].type !== 'raw_app' ) { e.preventDefault() - homeSelection.toggle( - toBulkItem(displayedItems[selectedIndex], $userStore, $workspaceStore), - e.shiftKey - ) + homeSelection.toggle(bulkKey(displayedItems[selectedIndex]), e.shiftKey) } else if (selectedIndex === loadMoreIndex && hasMore) { e.preventDefault() loadMoreAndPreselectFirstNew() diff --git a/frontend/src/lib/components/home/homeSelection.dom.test.ts b/frontend/src/lib/components/home/homeSelection.dom.test.ts index 17ddad1be9..257d318d3f 100644 --- a/frontend/src/lib/components/home/homeSelection.dom.test.ts +++ b/frontend/src/lib/components/home/homeSelection.dom.test.ts @@ -41,19 +41,19 @@ describe('HomeSelection shift-range', () => { it('selects the span between the anchor and the clicked row', () => { const s = rendered(a, b, c) - s.toggle(a) + s.toggle(a.key) - s.toggle(c, true) + s.toggle(c.key, true) expect(s.items.map((i) => i.key)).toEqual([a.key, b.key, c.key]) }) it('does not reach back into a selection the user already emptied', () => { const s = rendered(a, b, c) - s.toggle(a) - s.toggle(a) + s.toggle(a.key) + s.toggle(a.key) - s.toggle(c, true) + s.toggle(c.key, true) expect(s.items.map((i) => i.key)).toEqual([c.key]) }) diff --git a/frontend/src/lib/components/home/homeSelection.svelte.ts b/frontend/src/lib/components/home/homeSelection.svelte.ts index 0b4cfcba7a..45523c10ca 100644 --- a/frontend/src/lib/components/home/homeSelection.svelte.ts +++ b/frontend/src/lib/components/home/homeSelection.svelte.ts @@ -150,16 +150,22 @@ export class HomeSelection { else this.anchor = undefined } - toggle(item: BulkItem, range = false): void { - if (range && this.anchor != undefined && this.anchor !== item.key) { - if (this.selectRange(this.anchor, item.key)) return + /** Ticks a rendered row by key. The entry stored is the one the row registered, so a click + * and a keyboard tick can never record different rights for the same row. */ + toggle(key: string, range = false): void { + if (range && this.anchor != undefined && this.anchor !== key) { + if (this.selectRange(this.anchor, key)) return + } + if (this.selected.has(key)) this.selected.delete(key) + else { + const item = this.registry.get(key) + if (!item) return + this.selected.set(key, item) } - if (this.selected.has(item.key)) this.selected.delete(item.key) - else this.selected.set(item.key, item) // Unticking the last row ends selection mode, so the row it was on is not an // anchor any more: the next shift-click would otherwise reach back and select // everything between it and the row the user just started from. - this.anchor = this.selected.size === 0 ? undefined : item.key + this.anchor = this.selected.size === 0 ? undefined : key } /** Visual order is read back from the DOM: the tree nests rows and pages them diff --git a/frontend/src/lib/components/home/homeSelection.test.ts b/frontend/src/lib/components/home/homeSelection.test.ts index 25502d7eaf..8c74d2736f 100644 --- a/frontend/src/lib/components/home/homeSelection.test.ts +++ b/frontend/src/lib/components/home/homeSelection.test.ts @@ -24,7 +24,7 @@ function selectionOf(...items: BulkItem[]): HomeSelection { s.available = true for (const i of items) { s.register(i) - s.toggle(i) + s.toggle(i.key) } return s } diff --git a/frontend/src/lib/components/home/treeViewUtils.ts b/frontend/src/lib/components/home/treeViewUtils.ts index a36fbbee90..3269017c0b 100644 --- a/frontend/src/lib/components/home/treeViewUtils.ts +++ b/frontend/src/lib/components/home/treeViewUtils.ts @@ -1,6 +1,5 @@ import type { ListableApp, Script, Flow, ListableRawApp, ListableResource } from '$lib/gen' type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & { - canWrite: boolean marked?: string type?: U time?: number diff --git a/frontend/src/lib/components/resources/ResourcesList.svelte b/frontend/src/lib/components/resources/ResourcesList.svelte index b592d0b86c..90323c4ca2 100644 --- a/frontend/src/lib/components/resources/ResourcesList.svelte +++ b/frontend/src/lib/components/resources/ResourcesList.svelte @@ -99,14 +99,13 @@ const editRights = useEditRights() const hosted = useHostedPage() - type ResourceW = ListableResource & { canWrite: boolean; marked?: string } - type ResourceTypeW = ResourceType & { canWrite: boolean } + type ResourceW = ListableResource & { marked?: string } let cacheResources: ResourceW[] | undefined = $state() let stateResources: ResourceW[] | undefined = $state() let themeResources: ResourceW[] | undefined = $state() let resources: ResourceW[] | undefined = $state() - let resourceTypes: ResourceTypeW[] | undefined = $state() + let resourceTypes: ResourceType[] | undefined = $state() // Collect unique paths, types, owners for filter autocomplete let allPaths: string[] = $state([]) @@ -332,14 +331,7 @@ apiParams.label = currentFilters.label } - const result = (await ResourceService.listResource(apiParams)).map((x) => { - return { - canWrite: - editRights.canEditItem('resource', x.path, x.extra_perms) && - $operatingWorkspace! == x.workspace_id, - ...x - } - }) + const result = await ResourceService.listResource(apiParams) // Extract unique values for autocomplete allPaths = Array.from(new Set(result.map((x) => x.path))).sort() @@ -357,12 +349,7 @@ async function loadResourceTypes(): Promise<void> { const rows = await ResourceService.listResourceType({ workspace: $operatingWorkspace! }) setResourceTypeDisplayNames(rows) - resourceTypes = rows.map((x) => { - return { - canWrite: $operatingWorkspace! == x.workspace_id, - ...x - } - }) + resourceTypes = rows loading.types = false } @@ -686,9 +673,11 @@ } }) $effect(() => { - if ($operatingWorkspace && operatingUser.current) { + if ($operatingWorkspace) { untrack(() => { - loadResources() + // The effect above loads the open tab only; the workspace list must not keep the + // previous workspace's rows behind another tab. + if (tab !== 'workspace') loadResources() loadResourceTypes() }) } @@ -1194,9 +1183,12 @@ </Head> <tbody class="divide-y bg-surface"> {#if filteredItems} - {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} + {#each filteredItems as { path, description, resource_type, extra_perms, workspace_id, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} {@const hasDraft = getLocalDraftHint($operatingWorkspace, 'resource', path) ?? is_draft} + {@const canWrite = + editRights.canEditItem('resource', path, extra_perms) && + workspace_id == $operatingWorkspace} <Row> <Cell first> <SharedBadge {canWrite} extraPerms={extra_perms} /> @@ -1493,7 +1485,8 @@ </Head> <tbody class="divide-y bg-surface"> {#if filteredResourceTypes} - {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} + {#each filteredResourceTypes as { name, description, schema, workspace_id, format_extension, is_fileset }} + {@const canWrite = workspace_id == $operatingWorkspace} <Row> <Cell first> <a diff --git a/frontend/src/lib/components/schedules/SchedulesList.svelte b/frontend/src/lib/components/schedules/SchedulesList.svelte index 53a4ebf256..32101d09d5 100644 --- a/frontend/src/lib/components/schedules/SchedulesList.svelte +++ b/frontend/src/lib/components/schedules/SchedulesList.svelte @@ -78,11 +78,9 @@ else scheduleEditor?.openEdit(path, isFlow) } - type ScheduleW = ScheduleWJobs & { canWrite: boolean } - const MISSED_VISIBLE_MS = 7 * 24 * 60 * 60 * 1000 - let schedules: ScheduleW[] = $state([]) + let schedules: ScheduleWJobs[] = $state([]) let shareModal: ShareModal | undefined = $state() let loading = $state(true) let loadingSchedulesWithJobStats = $state(true) @@ -144,9 +142,7 @@ apiParams.label = currentFilters.label } - const result = (await ScheduleService.listSchedules(apiParams)).map((x) => { - return { canWrite: canWrite(x.path, x.extra_perms!, operatingUser.current), ...x } - }) + const result: ScheduleWJobs[] = await ScheduleService.listSchedules(apiParams) if (generation !== loadGeneration) return // Extract unique values for autocomplete @@ -164,20 +160,16 @@ loadSchedulesWithJobStats(generation) } - // One effect for every reason to reload (filters, workspace, user), so that mounting - // issues a single load rather than one per reason. Waits for the user lookup to settle, - // not for a user: a failed lookup still lists, read-only. $effect(() => { filters.val - operatingUser.current - if ($operatingWorkspace && operatingUser.resolved($operatingWorkspace)) { + if ($operatingWorkspace) { untrack(() => loadSchedules()) } }) async function loadSchedulesWithJobStats(generation: number): Promise<void> { loadingSchedulesWithJobStats = true - let schedulesWithJobsByPath = new Map<string, ScheduleW>() + let schedulesWithJobsByPath = new Map<string, ScheduleWJobs>() let schedulesWithJobsList = await ScheduleService.listSchedulesWithJobs({ workspace: $operatingWorkspace! }) @@ -343,7 +335,7 @@ }) function filterItemsPathsBaseOnUserFilters( - item: ScheduleW, + item: ScheduleWJobs, selectedFilterKind: 'schedule' | 'script_flow', userFoldersOnly: boolean ) { @@ -365,7 +357,7 @@ } function filterItemsBasedOnEnabledDisabled( - item: ScheduleW, + item: ScheduleWJobs, filterEnabledDisabled: 'all' | 'enabled' | 'disabled' ) { // Draft-only rows have nothing deployed, so they read as disabled here @@ -391,7 +383,7 @@ let items = $derived(filteredItems) </script> -{#snippet scheduleRow(s: ScheduleW, inTree: boolean, depth: number)} +{#snippet scheduleRow(s: ScheduleWJobs, inTree: boolean, depth: number)} {@const { path, error, @@ -404,7 +396,6 @@ script_path, is_flow, extra_perms, - canWrite, jobs, late_run_streak, missed_occurrences, @@ -493,7 +484,10 @@ </div> <div class="hidden lg:flex flex-row gap-1 items-center"> - <SharedBadge {canWrite} extraPerms={extra_perms} /> + <SharedBadge + canWrite={canWrite(path, extra_perms!, operatingUser.current)} + extraPerms={extra_perms} + /> </div> <div class="w-10"> @@ -708,7 +702,7 @@ {/snippet} {#snippet treeLeaf(item: ItemType, depth: number)} - {@render scheduleRow(item as unknown as ScheduleW, true, depth)} + {@render scheduleRow(item as unknown as ScheduleWJobs, true, depth)} {/snippet} <DeployWorkspaceDrawer bind:this={deploymentDrawer} /> diff --git a/frontend/src/lib/components/triggers/TriggerList.svelte b/frontend/src/lib/components/triggers/TriggerList.svelte index 3931d9660c..649ff6412a 100644 --- a/frontend/src/lib/components/triggers/TriggerList.svelte +++ b/frontend/src/lib/components/triggers/TriggerList.svelte @@ -129,9 +129,7 @@ } const Icon = $derived(ICONS[triggerKind]) - type TriggerW = TriggerRow & { canWrite: boolean } - - let triggers: TriggerW[] = $state([]) + let triggers: TriggerRow[] = $state([]) let loading = $state(true) let editor: EditorHandle | undefined = $state() let shareModal: ShareModal | undefined = $state() @@ -171,10 +169,7 @@ async function loadTriggers(): Promise<void> { const workspace = $operatingWorkspace! const listed = await config.list({ workspace, includeDraftOnly: true }) - triggers = listed.map((x) => ({ - canWrite: canWrite(x.path, x.extra_perms!, operatingUser.current), - ...x - })) + triggers = listed // The nav rail lists the trigger kinds of the navigation workspace only. if (workspace === $workspaceStore) { $usedTriggerKinds = removeTriggerKindIfUnused( @@ -188,7 +183,7 @@ } $effect(() => { - if ($operatingWorkspace && operatingUser.current) { + if ($operatingWorkspace) { untrack(() => loadTriggers()) } }) @@ -253,7 +248,7 @@ return committed } - async function deleteTrigger(row: TriggerW) { + async function deleteTrigger(row: TriggerRow) { const workspace = $operatingWorkspace ?? '' if (triggerKind === 'postgres' || triggerKind === 'gcp' || triggerKind === 'azure') { isDeleting = false @@ -280,7 +275,7 @@ // Kinds whose trigger owns an external subscription (a replication slot and publication, a // cloud subscription) confirm the delete, offering to drop those too. - let pendingDelete: TriggerW | undefined = $state(undefined) + let pendingDelete: TriggerRow | undefined = $state(undefined) let isDeleting = $state(false) let deleteA = $state(false) let deleteB = $state(false) @@ -370,7 +365,7 @@ } }) - let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([]) + let filteredItems: (TriggerRow & { marked?: any })[] | undefined = $state([]) let filter = $state('') let ownerFilter: string | undefined = $state(undefined) let nbDisplayed = $state(15) @@ -428,7 +423,7 @@ }) }) - function filterItemsPathsBaseOnUserFilters(item: TriggerW) { + function filterItemsPathsBaseOnUserFilters(item: TriggerRow) { if ($operatingWorkspace == 'admins') return true if (!filterUserFolders) return true const path = selectedFilterKind === 'trigger' ? item.path : item.script_path @@ -524,7 +519,7 @@ const canCreate = $derived(!config.adminOnly || isAdmin) </script> -{#snippet rowTitle(row: TriggerW & { marked?: any }, hasDraft: boolean | undefined)} +{#snippet rowTitle(row: TriggerRow & { marked?: any }, hasDraft: boolean | undefined)} {@const marked = ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind) ? row.marked : undefined} @@ -576,7 +571,7 @@ </div> {/snippet} -{#snippet statusDot(row: TriggerW)} +{#snippet statusDot(row: TriggerRow)} {@const status = config.status} {#if status} {@const ping = status.pings @@ -672,7 +667,7 @@ </div> </ConfirmationModal> -{#snippet triggerRow(row: TriggerW & { marked?: any }, inTree: boolean, depth: number)} +{#snippet triggerRow(row: TriggerRow & { marked?: any }, inTree: boolean, depth: number)} {@const { path, edited_by, @@ -680,7 +675,6 @@ script_path, is_flow, extra_perms, - canWrite, mode, retry, error_handler_path, @@ -720,7 +714,10 @@ </a> <div class="hidden lg:flex flex-row gap-1 items-center"> - <SharedBadge {canWrite} extraPerms={extra_perms} /> + <SharedBadge + canWrite={canWrite(path, extra_perms!, operatingUser.current)} + extraPerms={extra_perms} + /> {#if triggerKind !== 'http' && triggerKind !== 'azure' && labels?.length} {#each labels as label} <Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge> @@ -854,7 +851,7 @@ {/snippet} {#snippet treeLeaf(item: ItemType, depth: number)} - {@render triggerRow(item as unknown as TriggerW & { marked?: any }, true, depth)} + {@render triggerRow(item as unknown as TriggerRow & { marked?: any }, true, depth)} {/snippet} <DeployWorkspaceDrawer bind:this={deploymentDrawer} /> diff --git a/frontend/src/lib/components/variables/VariablesList.svelte b/frontend/src/lib/components/variables/VariablesList.svelte index c01eb5dbd4..565be7a04a 100644 --- a/frontend/src/lib/components/variables/VariablesList.svelte +++ b/frontend/src/lib/components/variables/VariablesList.svelte @@ -76,9 +76,7 @@ else variableEditor?.editVariable(path) } - type ListableVariableW = ListableVariable & { canWrite: boolean } - - let variables = $state(undefined) as ListableVariableW[] | undefined + let variables = $state(undefined) as ListableVariable[] | undefined let showCreateButtons = $state(false) // Collect unique values for filter autocomplete @@ -207,14 +205,7 @@ apiParams.label = currentFilters.label } - const result = (await VariableService.listVariable(apiParams)).map((x) => { - return { - canWrite: - editRights.canEditItem('variable', x.path, x.extra_perms) && - x.workspace_id == $operatingWorkspace, - ...x - } - }) + const result = await VariableService.listVariable(apiParams) // Extract unique values for autocomplete allPaths = Array.from(new Set(result.map((x) => x.path))).sort() @@ -265,11 +256,8 @@ } $effect(() => { - if ($operatingWorkspace && operatingUser.current) { - untrack(() => { - loadVariables() - loadContextualVariables() - }) + if ($operatingWorkspace) { + untrack(() => loadContextualVariables()) } }) let tab: 'workspace' | 'contextual' = $state('workspace') @@ -442,9 +430,12 @@ </tr> </Head> <tbody class="divide-y"> - {#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft, value_valid_until }} + {#each filteredItems as { path, value, is_secret, description, extra_perms, workspace_id, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft, value_valid_until }} {@const hasDraft = getLocalDraftHint($operatingWorkspace, 'variable', path) ?? is_draft} + {@const canWrite = + editRights.canEditItem('variable', path, extra_perms) && + workspace_id == $operatingWorkspace} <Row> <Cell class="!px-0 text-center w-12" first> <SharedBadge {canWrite} extraPerms={extra_perms} /> diff --git a/frontend/src/lib/operatorWriteRights.ts b/frontend/src/lib/operatorWriteRights.ts index 5684a8ee56..7ec8a86647 100644 --- a/frontend/src/lib/operatorWriteRights.ts +++ b/frontend/src/lib/operatorWriteRights.ts @@ -1,3 +1,4 @@ +import { getContext, setContext } from 'svelte' import { derived, fromStore, get, readable, toStore, type Readable } from 'svelte/store' import type { RoleCapabilities } from '$lib/gen' import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' @@ -49,6 +50,22 @@ export function useEditRights(workspace?: () => string | undefined) { } } +type EditRights = ReturnType<typeof useEditRights> +const EDIT_RIGHTS_KEY = Symbol('editRights') + +/** A list's rows all act as the same user in the same workspace, so they read the list's + * `useEditRights` instance rather than each setting up their own. */ +export function shareEditRights(rights: EditRights): EditRights { + setContext(EDIT_RIGHTS_KEY, rights) + return rights +} + +/** Only under a component that called `shareEditRights`: elsewhere there is no instance and the + * first check throws. */ +export function getSharedEditRights(): EditRights { + return getContext<EditRights>(EDIT_RIGHTS_KEY) +} + /** * The role's capabilities in `workspace`, from the workspace list alone: `$userStore` describes * only the navigation workspace, so elsewhere only superadmin carries over. Admins get full diff --git a/frontend/src/routes/(root)/(logged)/folders/+page.svelte b/frontend/src/routes/(root)/(logged)/folders/+page.svelte index ab458f90de..e71cedc113 100644 --- a/frontend/src/routes/(root)/(logged)/folders/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/folders/+page.svelte @@ -21,28 +21,17 @@ import { untrack } from 'svelte' import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud' - type FolderW = Folder & { canWrite: boolean } - let restricted = $derived( isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin) ) - let folders: FolderW[] | undefined = $state(undefined) + let folders: Folder[] | undefined = $state(undefined) let folderEditorDrawer: FolderEditorDrawer | undefined = $state() let hubDrawer: Drawer | undefined = $state() let publishFolderName: string = $state('') async function loadFolders(): Promise<void> { - folders = (await FolderService.listFolders({ workspace: $workspaceStore! })).map((x) => { - return { - canWrite: - $userStore != undefined && - ($userStore?.is_admin || - $userStore?.is_super_admin || - $userStore?.folders_owners.includes(x.name)), - ...x - } - }) + folders = await FolderService.listFolders({ workspace: $workspaceStore! }) } function onFolderSaved(name: string, created: boolean) { @@ -53,7 +42,7 @@ } $effect(() => { - if ($workspaceStore && $userStore) { + if ($workspaceStore) { untrack(() => { loadFolders() }) @@ -164,7 +153,12 @@ </tr> {/each} {:else} - {#each folders as { name, extra_perms, owners, canWrite, summary, labels } (name)} + {#each folders as { name, extra_perms, owners, summary, labels } (name)} + {@const canWrite = + $userStore != undefined && + ($userStore.is_admin || + $userStore.is_super_admin || + $userStore.folders_owners.includes(name))} <Row hoverable on:click={() => folderEditorDrawer?.initEdit(name)}> <Cell first> <span class="text-emphasis text-xs font-semibold">{name}</span> diff --git a/frontend/src/routes/(root)/(logged)/groups/+page.svelte b/frontend/src/routes/(root)/(logged)/groups/+page.svelte index 9d7a70ca7e..b99d329f40 100644 --- a/frontend/src/routes/(root)/(logged)/groups/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/groups/+page.svelte @@ -22,22 +22,18 @@ import { Tooltip } from '$lib/components/meltComponents' import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud' - type GroupW = Group & { canWrite: boolean } - const editRights = useEditRights() let restricted = $derived( isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin) ) - let groups: GroupW[] | undefined = $state(undefined) + let groups: Group[] | undefined = $state(undefined) let instanceGroups: InstanceGroupWithWorkspaces[] | undefined = $state(undefined) let groupEditorDrawer: GroupEditorDrawer | undefined = $state() async function loadGroups(): Promise<void> { - groups = (await GroupService.listGroups({ workspace: $workspaceStore! })).map((x) => { - return { canWrite: editRights.canEditItem('group', x.name, x.extra_perms), ...x } - }) + groups = await GroupService.listGroups({ workspace: $workspaceStore! }) } async function loadInstanceGroups(): Promise<void> { @@ -50,7 +46,7 @@ $effect(() => { untrack(() => loadInstanceGroups()) - if ($workspaceStore && $userStore) { + if ($workspaceStore) { untrack(() => loadGroups()) } }) @@ -127,7 +123,8 @@ </tr> {/each} {:else} - {#each groups as { name, summary, extra_perms, canWrite } (name)} + {#each groups as { name, summary, extra_perms } (name)} + {@const canWrite = editRights.canEditItem('group', name, extra_perms)} <Row hoverable on:click={() => groupEditorDrawer?.initEdit(name)}> <Cell first> <div class="flex flex-row gap-2 justify-between"> From bac1e463e5759add52b34e0555d2530d671b2251 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel <ruben@windmill.dev> Date: Tue, 27 Oct 2026 00:40:39 +0100 Subject: [PATCH 09/14] feat: add ai review to fork deploys and deployment requests (#12029) * feat: add ai review to deployment requests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: bound ai review fetches and surface unreadable items Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: add fix in ai session and model picker to deployment ai review Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: add workspace instructions for deployment ai review Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: read review instructions from the parent and bind ai review to the fork Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: pin the fix-all session preview to the fork's compare page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: drop workspace-settings instructions from deployment ai review Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: review the selected items with ai on compare and deploy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: mark an ai review out of date when its items change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: post all ai review findings as comments at once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to 77703cf32e58964906d87ee83ddda44b5b8d1b7e This commit updates the EE repository reference after PR #926 was merged in windmill-ee-private. Previous ee-repo-ref: e30ab9e69d1854fcaed5620c934ace32acb0a729 New ee-repo-ref: 77703cf32e58964906d87ee83ddda44b5b8d1b7e Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> --- backend/ee-repo-ref.txt | 2 +- docs/feature-telemetry.md | 9 +- .../lib/components/CompareWorkspaces.svelte | 18 +- .../deploymentRequest/AiReview.svelte | 273 ++++++++++++++++++ .../DeploymentRequestPanel.svelte | 46 ++- .../deploymentRequest/aiReview.test.ts | 84 ++++++ .../components/deploymentRequest/aiReview.ts | 188 ++++++++++++ 7 files changed, 610 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/components/deploymentRequest/AiReview.svelte create mode 100644 frontend/src/lib/components/deploymentRequest/aiReview.test.ts create mode 100644 frontend/src/lib/components/deploymentRequest/aiReview.ts diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index a0a07e91e0..b29a21d710 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -747336910e5c7498544613ad7863cea59f4db6a1 +77703cf32e58964906d87ee83ddda44b5b8d1b7e diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index 0862453020..af0dd55942 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,12 +4,13 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 78 registered actions across twenty-nine features (`ai_session`, `ai_chat`, +It currently carries 86 registered actions across thirty-three features (`ai_session`, `ai_chat`, `ai_fix`, `ai_agent`, `ai_decision`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`, `flow_run`, `flow_step`, `home`, `header_nav`, `run_form`, `debugger`, `trigger`, `command_script`, -`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`, `job_token_scopes`, -`worker_draining`, `worker_group`, `worker_resource_stats`, `dev_workspace_isolation`, -`slack_agent`, `variable_expiration`). Nearly all of the +`mcp`, `mcp_workspace`, `hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`, +`job_token_scopes`, `worker_draining`, `worker_group`, `worker_resource_stats`, +`dev_workspace_isolation`, `slack_agent`, `variable_expiration`, `anthropic_federation`, +`deployment_request`). Nearly all of the product is uninstrumented, so new user-facing work is the opportunity to change that. ## When to instrument diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 096bc290c4..083437f8f7 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -65,6 +65,8 @@ import { deepEqual } from 'fast-equals' import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte' import DeploymentRequestPanel from './deploymentRequest/DeploymentRequestPanel.svelte' + import AiReview from './deploymentRequest/AiReview.svelte' + import { copilotInfo } from '$lib/aiStore' import { userStore } from '$lib/stores' import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte' import CompareTargetPicker from './CompareTargetPicker.svelte' @@ -430,6 +432,7 @@ ) let selectedItems = $state<string[]>([]) + let selectedDiffs = $derived(selectableDiffs.filter((d) => selectedItems.includes(getItemKey(d)))) // Selected items that carry a pending draft. They're opt-in (excluded from the // default selection), so a non-empty list means the user explicitly picked an @@ -1807,8 +1810,18 @@ {#snippet footer()} {#if !nothingToAct} - <div class="flex items-center justify-between"> - <div></div> + <div class="flex items-start justify-between gap-4"> + <div class="flex-1 min-w-0"> + <!-- The request form and an open request carry their own review. --> + {#if mergeIntoParent && $copilotInfo.enabled && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()} + <AiReview + diffs={selectedDiffs} + forkWorkspaceId={currentWorkspaceId} + {parentWorkspaceId} + source="selection" + /> + {/if} + </div> <div class="flex flex-col items-end gap-2"> <!-- Always show the deploy footer: only the items visible to the user are @@ -1885,6 +1898,7 @@ {parentWorkspaceId} currentUsername={$userStore?.username ?? ''} isAdmin={$userStore?.is_admin ?? false} + diffs={comparison?.diffs.filter((d) => diffActionableInDirection(d, true)) ?? []} onStateChange={(open) => { hasOpenDeploymentRequest = open }} diff --git a/frontend/src/lib/components/deploymentRequest/AiReview.svelte b/frontend/src/lib/components/deploymentRequest/AiReview.svelte new file mode 100644 index 0000000000..8e7cfbbdd0 --- /dev/null +++ b/frontend/src/lib/components/deploymentRequest/AiReview.svelte @@ -0,0 +1,273 @@ +<script lang="ts"> + import { Alert, Badge, Button } from '$lib/components/common' + import type { AIProviderModel, WorkspaceItemDiff } from '$lib/gen' + import { copilotInfo, copilotSessionModel } from '$lib/aiStore' + import Select from '$lib/components/select/Select.svelte' + import { sendUserToast } from '$lib/toast' + import { logFeatureUsage } from '$lib/utils/featureUsage' + import { Loader2, MessageSquarePlus, Sparkles, WandSparkles, X } from 'lucide-svelte' + import OpenInSessionButton, { + type OpenInSessionSource + } from '$lib/components/sessions/OpenInSessionButton.svelte' + 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' + + 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' + /** 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 running = $state(false) + let result = $state<AiReviewResult | undefined>(undefined) + let error = $state<string | undefined>(undefined) + let postingId = $state<string | undefined>(undefined) + let abortController: AbortController | undefined + + // `diffs` 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[]) => + 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)) + + const modelKey = (m: AIProviderModel) => `${m.provider}:${m.model}` + // Local to this review: picking a model here must not change the chat's own selection, + // which is only the default until something is picked. + let pickedModelKey = $state<string | undefined>(undefined) + let chatModel = $derived( + $copilotSessionModel ?? $copilotInfo.defaultModel ?? $copilotInfo.aiModels[0] + ) + let selectedModelKey = $derived(pickedModelKey ?? (chatModel && modelKey(chatModel))) + + async function run() { + abortController?.abort() + const controller = new AbortController() + abortController = controller + running = true + error = undefined + result = undefined + reviewed = { key: itemsKey(diffs), count: diffs.length } + logFeatureUsage('deployment_request', 'ai_review', { key: source }) + try { + const review = await reviewDeployment(diffs, forkWorkspaceId, parentWorkspaceId, controller, { + model: $copilotInfo.aiModels.find((m) => modelKey(m) === selectedModelKey) + }) + if (!controller.signal.aborted) result = review + } catch (e: any) { + if (!controller.signal.aborted) error = e?.message ?? String(e) + } finally { + if (abortController === controller) running = false + } + } + + $effect(() => () => abortController?.abort()) + + function stop() { + abortController?.abort() + running = false + } + + function dismiss(finding: AiReviewFinding) { + if (result) result.findings = result.findings.filter((f) => f.id !== finding.id) + } + + async function post(finding: AiReviewFinding): Promise<boolean> { + if (!onPost) return false + postingId = finding.id + try { + await onPost(finding) + logFeatureUsage('deployment_request', 'ai_finding_posted', { key: finding.severity }) + dismiss(finding) + return true + } catch (e: any) { + sendUserToast(`Failed to post comment: ${e?.body || e?.message}`, true) + return false + } finally { + postingId = undefined + } + } + + let postingAll = $state(false) + + // One at a time, in the listed order, stopping at the first failure: posted findings leave + // the list, so a retry posts only what is left. + async function postAll() { + postingAll = true + try { + for (const f of [...(result?.findings ?? [])]) { + if (!(await post(f))) break + } + } finally { + postingAll = false + } + } + + const severityColor = { high: 'red', medium: 'yellow', low: 'gray' } as const + + 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. + 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')}`, + autoSend: true, + beforeOpen: () => + logFeatureUsage('deployment_request', 'ai_fix_in_session', { + key: one ? 'finding' : 'all' + }) + } + if (one && SESSION_TARGET_KINDS.includes(one.kind)) { + return { ...common, target: { kind: one.kind as SessionTarget['kind'], path: one.path } } + } + return { + ...common, + page: () => buildCompareUrl({ workspace_id: forkWorkspaceId, mode: 'fork' }) + } + } +</script> + +<div class="flex flex-col gap-2"> + <div class="flex items-center gap-2"> + {#if running} + <Button variant="default" unifiedSize="sm" startIcon={{ icon: X }} onclick={stop}> + Stop review + </Button> + <span class="flex items-center gap-1 text-xs text-secondary"> + <Loader2 size={12} class="animate-spin" /> + Reviewing {reviewed?.count} changed item{reviewed?.count === 1 ? '' : 's'}… + </span> + {:else} + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Sparkles }} + disabled={diffs.length === 0} + onclick={run} + > + {result ? 'Review again with AI' : 'Review with AI'} + </Button> + {#if $copilotInfo.aiModels.length > 1} + <div class="w-56"> + <Select + size="sm" + items={$copilotInfo.aiModels.map((m) => ({ label: m.model, value: modelKey(m) }))} + bind:value={() => selectedModelKey, (v) => (pickedModelKey = v)} + /> + </div> + {/if} + {#if diffs.length === 0} + <span class="text-xs text-secondary"> + {source === 'selection' ? 'Select items to review.' : 'No changes to review.'} + </span> + {/if} + {/if} + </div> + + {#if error} + <Alert type="error" title="AI review failed" size="xs">{error}</Alert> + {/if} + + {#if result} + <div class="flex flex-col gap-2 border rounded-md p-2 bg-surface-secondary"> + {#if stale} + <Alert type="warning" title="Out of date" size="xs"> + The items have changed since this review. Review again to cover the current ones. + </Alert> + {/if} + {#if result.summary} + <div class="text-xs text-secondary">{result.summary}</div> + {/if} + {#if result.unreadable.length > 0} + <Alert type="warning" title="Not reviewed" size="xs"> + Could not load the contents of: {result.unreadable.join(', ')} + </Alert> + {/if} + {#if result.omitted.length > 0} + <div class="text-2xs text-secondary"> + Reviewed by name only, too large to include: {result.omitted.join(', ')} + </div> + {/if} + {#if result.findings.length > 1} + <div class="flex gap-2 self-start"> + <OpenInSessionButton + 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" + btnProps={{ iconOnly: false, unifiedSize: 'xs', startIcon: { icon: WandSparkles } }} + /> + {#if onPost} + <Button + variant="default" + unifiedSize="xs" + startIcon={{ icon: MessageSquarePlus }} + disabled={postingId != undefined || postingAll} + loading={postingAll} + onclick={postAll} + > + Post all as comments + </Button> + {/if} + </div> + {/if} + {#if result.findings.length === 0} + <div class="text-xs text-secondary">No issues left to review.</div> + {:else if source === 'request_form'} + <div class="text-2xs text-secondary"> + Fix these in your fork before sending, or send as is. Reviewers can run their own review. + </div> + {/if} + {#each result.findings as f (f.id)} + <div class="border rounded-md p-2 flex flex-col gap-1 bg-surface"> + <div class="flex items-center gap-2 flex-wrap text-xs"> + <Badge color={severityColor[f.severity]} size="xs">{f.severity}</Badge> + {#if f.kind && f.path} + <Badge color="blue" size="xs">{f.kind}:{f.path}</Badge> + {/if} + </div> + <div class="text-xs text-primary whitespace-pre-wrap">{f.comment}</div> + <div class="flex gap-2 self-end"> + <OpenInSessionButton + source={fixSource([f])} + btnClasses={AIBtnClasses('default')} + label="Fix in AI session" + tooltip="Open an AI session in the fork and fix this issue" + btnProps={{ iconOnly: false, unifiedSize: 'xs', startIcon: { icon: WandSparkles } }} + /> + {#if onPost} + <Button + variant="default" + unifiedSize="xs" + startIcon={{ icon: MessageSquarePlus }} + disabled={postingId != undefined || postingAll} + loading={postingId === f.id} + onclick={() => post(f)} + > + Post as comment + </Button> + {/if} + <Button variant="subtle" unifiedSize="xs" onclick={() => dismiss(f)}>Dismiss</Button> + </div> + </div> + {/each} + </div> + {/if} +</div> diff --git a/frontend/src/lib/components/deploymentRequest/DeploymentRequestPanel.svelte b/frontend/src/lib/components/deploymentRequest/DeploymentRequestPanel.svelte index c57d7a2a98..35196eeedb 100644 --- a/frontend/src/lib/components/deploymentRequest/DeploymentRequestPanel.svelte +++ b/frontend/src/lib/components/deploymentRequest/DeploymentRequestPanel.svelte @@ -8,21 +8,33 @@ WorkspaceService, type DeploymentRequest, type DeploymentRequestComment, - type DeploymentRequestEligibleDeployer + type DeploymentRequestEligibleDeployer, + type WorkspaceItemDiff } from '$lib/gen' import { sendUserToast } from '$lib/toast' + import { copilotInfo } from '$lib/aiStore' import { Send, Plus, X, ArchiveX } from 'lucide-svelte' + import AiReview from './AiReview.svelte' + import type { AiReviewFinding } from './aiReview' interface Props { forkWorkspaceId: string parentWorkspaceId: string currentUsername: string isAdmin: boolean + /** The changes the request would merge into the parent, for the AI review. */ + diffs: WorkspaceItemDiff[] onStateChange?: (hasOpenRequest: boolean) => void } - let { forkWorkspaceId, parentWorkspaceId, currentUsername, isAdmin, onStateChange }: Props = - $props() + let { + forkWorkspaceId, + parentWorkspaceId, + currentUsername, + isAdmin, + diffs, + onStateChange + }: Props = $props() let request = $state<DeploymentRequest | null>(null) let loading = $state(true) @@ -114,6 +126,20 @@ } } + async function postAiFinding(finding: AiReviewFinding): Promise<void> { + if (!request) return + const created = await WorkspaceService.createDeploymentRequestComment({ + workspace: forkWorkspaceId, + id: request.id, + requestBody: { + body: finding.comment, + anchor_kind: finding.kind || undefined, + anchor_path: finding.path || undefined + } + }) + request = { ...request, comments: [...request.comments, created] } + } + async function postComment(body: string, parentId?: number) { if (!request || !body.trim()) return posting = true @@ -233,6 +259,11 @@ Cancel </Button> </div> + {#if $copilotInfo.enabled} + <div class="border-t pt-2"> + <AiReview {diffs} {forkWorkspaceId} {parentWorkspaceId} source="request_form" /> + </div> + {/if} </div> {:else} <div class="flex items-center justify-between flex-wrap gap-2 text-xs"> @@ -261,6 +292,15 @@ </div> <div class="border rounded-md bg-surface p-3 flex flex-col gap-3"> + {#if $copilotInfo.enabled} + <AiReview + {diffs} + {forkWorkspaceId} + {parentWorkspaceId} + source="open_request" + onPost={postAiFinding} + /> + {/if} {#if topLevel.length === 0} <div class="text-secondary text-xs">No comments yet. Be the first to leave feedback.</div> {/if} diff --git a/frontend/src/lib/components/deploymentRequest/aiReview.test.ts b/frontend/src/lib/components/deploymentRequest/aiReview.test.ts new file mode 100644 index 0000000000..1759919af8 --- /dev/null +++ b/frontend/src/lib/components/deploymentRequest/aiReview.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceItemDiff } from '$lib/gen' + +const h = vi.hoisted(() => ({ prompt: '', workspace: '' as string | undefined, reads: 0 })) + +vi.mock('$lib/components/copilot/lib', () => ({ + getNonStreamingCompletion: async ( + messages: { content: string }[], + _abort: AbortController, + options?: { workspace?: string } + ) => { + h.prompt = messages[1].content + h.workspace = options?.workspace + return '{"summary":"s","findings":[]}' + } +})) +// Mirrors the shared loader, which answers `{}` instead of throwing when a read fails. +vi.mock('$lib/utils_workspace_deploy', () => ({ + getItemValue: async (_kind: string, path: string) => { + h.reads++ + if (path === 'f/a/unreadable') return {} + return { content: path.startsWith('f/big/') ? 'x'.repeat(30_000) : 'x' } + } +})) + +const { parseReviewAnswer, reviewDeployment } = await import('./aiReview') + +function diff(path: string): WorkspaceItemDiff { + return { + kind: 'script', + path, + ahead: 1, + behind: 0, + has_changes: true, + exists_in_source: true, + exists_in_fork: true + } +} + +beforeEach(() => { + h.reads = 0 +}) + +describe('parseReviewAnswer', () => { + it('anchors only findings on items in the diff, and ranks by severity', () => { + const answer = + '```json\n' + + JSON.stringify({ + summary: 's', + findings: [ + { kind: 'flow', path: 'f/not/changed', severity: 'low', comment: 'phantom' }, + { kind: 'script', path: 'f/a/b', severity: 'high', comment: 'real' } + ] + }) + + '\n```' + const { findings } = parseReviewAnswer(answer, [diff('f/a/b')]) + expect(findings.map((f) => [f.comment, f.kind, f.path])).toEqual([ + ['real', 'script', 'f/a/b'], + ['phantom', '', ''] + ]) + }) +}) + +describe('reviewDeployment', () => { + it('reports an item whose read failed instead of reviewing it as emptied', async () => { + const result = await reviewDeployment( + [diff('f/a/b'), diff('f/a/unreadable')], + 'fork', + 'parent', + new AbortController() + ) + expect(result.unreadable).toEqual(['script:f/a/unreadable']) + expect(h.prompt).toContain('### script f/a/unreadable (modified)\nContents could not be read.') + expect(h.workspace).toBe('fork') + }) + + it('stops reading items once a section no longer fits the budget', async () => { + const diffs = Array.from({ length: 30 }, (_, i) => diff(`f/big/${i}`)) + const result = await reviewDeployment(diffs, 'fork', 'parent', new AbortController()) + // Only the first batch of 6 is read, both sides each. + expect(h.reads).toBe(12) + expect(result.omitted).toContain('script:f/big/29') + }) +}) diff --git a/frontend/src/lib/components/deploymentRequest/aiReview.ts b/frontend/src/lib/components/deploymentRequest/aiReview.ts new file mode 100644 index 0000000000..286c04a65a --- /dev/null +++ b/frontend/src/lib/components/deploymentRequest/aiReview.ts @@ -0,0 +1,188 @@ +import type { AIProviderModel, WorkspaceItemDiff } from '$lib/gen' +import { getNonStreamingCompletion } from '$lib/components/copilot/lib' +import { getItemValue } from '$lib/utils_workspace_deploy' +import type { Kind } from '$lib/utils_deployable' + +export type AiReviewSeverity = 'high' | 'medium' | 'low' + +export type AiReviewFinding = { + id: string + kind: string + path: string + severity: AiReviewSeverity + comment: string +} + +export type AiReviewResult = { + summary: string + findings: AiReviewFinding[] + /** Changed items whose contents did not fit in the prompt and were reviewed by name only. */ + omitted: string[] + /** Changed items whose contents failed to load, so the model did not review them. */ + unreadable: string[] +} + +// A variable's value is read decrypted and a resource's value holds credentials, so neither may +// reach the model: they are reviewed by name and change type only. +const VALUE_WITHHELD_KINDS = new Set(['variable', 'resource']) + +const PER_SIDE_CHAR_CAP = 20_000 +const TOTAL_CHAR_BUDGET = 160_000 +const FETCH_BATCH = 6 +// Under the Anthropic SDK's non-streaming pre-flight max_tokens limit. +const MAX_OUTPUT_TOKENS = 8_000 + +const SYSTEM_PROMPT = `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). 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: +- 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 +- hardcoded secrets or credentials +- deletions that look unintended + +Do not comment on style, naming, formatting or lockfiles, and do not praise. Do not repeat a finding for every item it touches. An item with no issue gets no finding. + +Answer with JSON only, no prose around it, in this 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>"}]}` + +function changeType(diff: WorkspaceItemDiff): 'added' | 'deleted' | 'modified' { + if (!diff.exists_in_source) return 'added' + if (!diff.exists_in_fork) return 'deleted' + return 'modified' +} + +function render(value: unknown): string { + if (value == null) return '(none)' + // Flows and apps nest their inline scripts' lockfiles, so they are dropped at any depth. + const text = + typeof value === 'string' + ? value + : JSON.stringify(value, (key, v) => (key === 'lock' ? undefined : v), 2) + return text.length > PER_SIDE_CHAR_CAP + ? `${text.slice(0, PER_SIDE_CHAR_CAP)}\n… (truncated, ${text.length - PER_SIDE_CHAR_CAP} more characters)` + : text +} + +async function itemSection( + diff: WorkspaceItemDiff, + forkWorkspaceId: string, + parentWorkspaceId: string +): Promise<{ text: string; unreadable: boolean }> { + const change = changeType(diff) + const header = `### ${diff.kind} ${diff.path} (${change})` + if (VALUE_WITHHELD_KINDS.has(diff.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)) { + return { text: `${header}\nContents could not be read.`, unreadable: true } + } + return { + text: `${header}\n#### Parent version\n${render(parent)}\n#### Fork version\n${render(fork)}`, + unreadable: false + } +} + +function isEmptyRead(value: unknown): boolean { + return value != null && typeof value === 'object' && Object.keys(value as object).length === 0 +} + +export function parseReviewAnswer( + text: string, + diffs: WorkspaceItemDiff[] +): Omit<AiReviewResult, 'omitted' | 'unreadable'> { + const start = text.indexOf('{') + const end = text.lastIndexOf('}') + if (start === -1 || end <= start) { + throw new Error('The AI answer was not in the expected format') + } + const parsed = JSON.parse(text.slice(start, end + 1)) as { + summary?: unknown + findings?: unknown + } + // Anchoring a comment needs a (kind, path) the fork's diff actually has, so a finding naming + // anything else is kept but unanchored rather than posted against a phantom item. + const known = new Set(diffs.map((d) => `${d.kind}:${d.path}`)) + const findings: AiReviewFinding[] = [] + for (const [i, raw] of (Array.isArray(parsed.findings) ? parsed.findings : []).entries()) { + if (!raw || typeof raw.comment !== 'string' || !raw.comment.trim()) continue + const anchored = known.has(`${raw.kind}:${raw.path}`) + findings.push({ + id: String(i), + kind: anchored ? raw.kind : '', + path: anchored ? raw.path : '', + severity: ['high', 'medium', 'low'].includes(raw.severity) ? raw.severity : 'medium', + comment: raw.comment.trim() + }) + } + const rank: Record<AiReviewSeverity, number> = { high: 0, medium: 1, low: 2 } + findings.sort((a, b) => rank[a.severity] - rank[b.severity]) + return { summary: typeof parsed.summary === 'string' ? parsed.summary : '', findings } +} + +/** 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, + abortController: AbortController, + options?: { + /** Defaults to the chat's current model. */ + model?: AIProviderModel + } +): Promise<AiReviewResult> { + const included: string[] = [] + const omitted: string[] = [] + const unreadable: string[] = [] + let used = 0 + // Fetched a batch at a time so a large fork stops reading items once the budget is spent. + // The first section that does not fit spends it: `used` alone can stay under the cap forever. + let budgetSpent = false + for (let i = 0; i < diffs.length; i += FETCH_BATCH) { + const batch = diffs.slice(i, i + FETCH_BATCH) + if (budgetSpent) { + omitted.push(...batch.map((d) => `${d.kind}:${d.path}`)) + continue + } + const sections = await Promise.all( + batch.map((d) => itemSection(d, forkWorkspaceId, parentWorkspaceId)) + ) + if (abortController.signal.aborted) throw new Error('aborted') + sections.forEach(({ text: section, unreadable: failed }, j) => { + if (failed) unreadable.push(`${batch[j].kind}:${batch[j].path}`) + if (!budgetSpent && used + section.length <= TOTAL_CHAR_BUDGET) { + included.push(section) + used += section.length + } else { + budgetSpent = true + omitted.push(`${batch[j].kind}:${batch[j].path}`) + } + }) + } + let user = `Fork workspace: ${forkWorkspaceId}\nParent workspace: ${parentWorkspaceId}\n\n${included.join('\n\n')}` + if (omitted.length > 0) { + user += `\n\nAlso changed, contents not shown for size:\n${omitted.map((o) => `- ${o}`).join('\n')}` + } + + const answer = await getNonStreamingCompletion( + [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: user } + ], + 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. + { + maxTokensCap: MAX_OUTPUT_TOKENS, + forceModelProvider: options?.model, + workspace: forkWorkspaceId + } + ) + return { ...parseReviewAnswer(answer ?? '', diffs), omitted, unreadable } +} From 7b1dce5af05660ffbe1febbe04976af139569a0b Mon Sep 17 00:00:00 2001 From: hugocasa <hugo@casademont.ch> Date: Tue, 27 Oct 2026 00:42:47 +0100 Subject: [PATCH 10/14] feat: forward a pinned version through the hub project export proxy (#12022) The proxy rebuilds the hub URL instead of passing the request through, so a `?version=` on it was dropped and the current version exported in its place. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- backend/windmill-api/openapi.yaml | 7 +++++++ backend/windmill-api/src/hub_publish.rs | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d016a61976..fa372cc469 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -28424,6 +28424,13 @@ paths: description: folder scoping the Hub project source (`{workspace}:{folder}`) schema: type: string + - name: version + in: query + required: false + description: id of a reviewed project version to export instead of the current one (see the hub's `/projects/{slug}/versions`) + schema: + type: integer + minimum: 1 responses: "200": description: raw Hub response body (status code is passed through from the Hub) diff --git a/backend/windmill-api/src/hub_publish.rs b/backend/windmill-api/src/hub_publish.rs index 633ad88a34..4e79aba943 100644 --- a/backend/windmill-api/src/hub_publish.rs +++ b/backend/windmill-api/src/hub_publish.rs @@ -536,14 +536,26 @@ async fn publish_migrations( .await } +#[derive(Deserialize)] +struct ExportQuery { + version: Option<u32>, +} + // Export is owner-scoped only when re-exporting your own draft; approved -// projects are public, so the folder scope is optional here. +// projects are public, so the folder scope is optional here. `version` pins one +// reviewed version instead of the current one; it is forwarded explicitly because +// the hub request is rebuilt rather than proxied, so an unforwarded param would +// silently export the current version instead. async fn get_project_export( ctx: HubPublishCtx, Path((_workspace, slug)): Path<(String, ProjectSlug)>, + Query(query): Query<ExportQuery>, ) -> Result<impl IntoResponse, Error> { - ctx.get_maybe_unscoped(&format!("/projects/{}/export", slug)) - .await + let path = match query.version { + Some(v) => format!("/projects/{}/export?version={}", slug, v), + None => format!("/projects/{}/export", slug), + }; + ctx.get_maybe_unscoped(&path).await } async fn get_project_by_source(ctx: HubPublishCtx) -> Result<impl IntoResponse, Error> { From a5a4caf3543124ce1ed55bf0dc991cf4575b097c Mon Sep 17 00:00:00 2001 From: Guilhem <guilhemlemouel@gmail.com> Date: Tue, 27 Oct 2026 00:43:21 +0100 Subject: [PATCH 11/14] remove the home and AI sessions composer view transition (#12020) * fix: remove the home and AI sessions composer view transition Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: drop view transition rationale from composer comments Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- frontend/src/lib/assets/app.css | 43 --------- .../lib/components/copilot/chat/AIChat.svelte | 3 - .../copilot/chat/AIChatDisplay.svelte | 14 +-- .../copilot/chat/starter/smoothHeight.ts | 4 +- .../src/lib/components/home/HomeAIChat.svelte | 4 +- .../sessions/HomeSessionStarter.svelte | 4 +- .../components/sessions/SessionWrapper.svelte | 2 - ...tion.test.ts => homeComposerFocus.test.ts} | 2 +- .../components/sessions/homeComposerFocus.ts | 38 ++++++++ .../sessions/homeSessionsTransition.svelte.ts | 96 ------------------- .../src/routes/(root)/(logged)/+layout.svelte | 4 +- .../src/routes/(root)/(logged)/+page.svelte | 7 +- 12 files changed, 49 insertions(+), 172 deletions(-) rename frontend/src/lib/components/sessions/{homeSessionsTransition.test.ts => homeComposerFocus.test.ts} (90%) create mode 100644 frontend/src/lib/components/sessions/homeComposerFocus.ts delete mode 100644 frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index f7f9b53972..4217c95831 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -426,46 +426,3 @@ input[type='time']::-webkit-calendar-picker-indicator { .svelte-flow__edges { z-index: -10; } - -/* The composer's glide, defined once: the Home ↔ AI Sessions view transition below and the - in-page move on a session's first message (the spacer under AIChatDisplay's composer) both - read it. */ -:root { - --composer-glide-duration: 380ms; - --composer-glide-easing: cubic-bezier(0.2, 0.8, 0.2, 1); -} - -/* Home ↔ AI Sessions (homeSessionsTransition.svelte.ts). The composer glides from where it - was to where it lands; everything else, Home's list and the session's greeting included, is - the page cross-fading, kept short. The pseudo-elements hang off :root itself, so no space - before `::view-transition-*`: with one, the selector looks for a descendant and never - matches. */ -:root[data-home-sessions-transition]::view-transition-group(composer) { - animation-duration: var(--composer-glide-duration); - animation-timing-function: var(--composer-glide-easing); -} -:root[data-home-sessions-transition]::view-transition-old(root), -:root[data-home-sessions-transition]::view-transition-new(root) { - animation-duration: 150ms; -} -/* The session's greeting sits above where the composer lands, so it waits for the composer: - shown while the composer is still gliding, it reads as text placed in mid-air. It starts as - the composer settles and rises into place on the same easing, so the two read as one motion. - Leaving, it goes with the page. */ -:root[data-home-sessions-transition='enter']::view-transition-new(greeting) { - animation: greeting-in 260ms var(--composer-glide-easing) - calc(var(--composer-glide-duration) * 0.6) both; -} -:root[data-home-sessions-transition]::view-transition-old(greeting) { - animation-duration: 150ms; -} -@keyframes greeting-in { - from { - opacity: 0; - transform: translateY(4px); - filter: blur(2px); - } -} -:root[data-home-sessions-transition='fade'] * { - view-transition-name: none !important; -} diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 51a8dfa55b..9f284e5f98 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -20,7 +20,6 @@ wideLayout = false, emptyHint, starter = undefined, - viewTransitionNames = false, onSent = undefined, inputPreface, actingOn, @@ -45,7 +44,6 @@ wideLayout?: boolean emptyHint?: import('svelte').Snippet starter?: import('./starter/starterPrompts.svelte').ChatStarter - viewTransitionNames?: boolean onSent?: () => void inputPreface?: import('svelte').Snippet /** Forwarded to AIChatDisplay: where the session acts, first among the controls. */ @@ -222,7 +220,6 @@ {wideLayout} {emptyHint} {starter} - {viewTransitionNames} {onSent} {inputPreface} {actingOn} diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index a740c5fb5f..63a9989d9c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -118,7 +118,6 @@ wideLayout = false, emptyHint, starter = undefined, - viewTransitionNames = false, onSent = undefined, inputPreface, actingOn, @@ -160,11 +159,6 @@ * both mount it, so the two are one entry point rather than look-alikes. Replaces * `emptyHint`. */ starter?: ChatStarter - /** Names the composer for the view transition between Home and AI Sessions, so it - * glides between its two places, a send from Home included: that lands it under the - * session's first message. Only one chat on the page may carry the name: a duplicate - * makes the browser skip the transition. */ - viewTransitionNames?: boolean /** After the composer hands a new turn to the host. */ onSent?: () => void inputPreface?: Snippet @@ -865,10 +859,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> has none: there the composer is one control among the page's. --> {#if centreComposer} <div class="flex-1 min-h-0 flex flex-col justify-end"> - <div - class={columnClass} - style:view-transition-name={viewTransitionNames ? 'greeting' : undefined} - > + <div class={columnClass}> <SessionGreeting /> </div> </div> @@ -1003,7 +994,6 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> aria-label="Message composer" bind:this={composerEl} class="relative {columnClass} pb-2" - style:view-transition-name={viewTransitionNames ? 'composer' : undefined} onfocusin={(e) => { if (homeComposer && e.target instanceof HTMLTextAreaElement) composerOpened = true }} @@ -1450,7 +1440,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> Always mounted and shrunk on the first message rather than removed: flex-grow transitions, so the composer slides down to the bottom instead of jumping. --> <div - class="min-h-0 basis-0 transition-[flex-grow] duration-[var(--composer-glide-duration)] ease-[var(--composer-glide-easing)] motion-reduce:transition-none {centreComposer + class="min-h-0 basis-0 transition-[flex-grow] duration-[380ms] ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none {centreComposer ? 'grow' : 'grow-0'}" ></div> diff --git a/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts b/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts index 57336ef9b8..b6b9c654a9 100644 --- a/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts +++ b/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts @@ -45,8 +45,8 @@ export function smoothHeight(node: HTMLElement, params: SmoothHeightParams) { ro.observe(inner) } // No measuring at mount: the content's height is not settled yet (a newly mounted session - // composer reads ~60px too tall), and a view transition captures the box at that height. - // The observer's first callback comes after layout and sets the right one. + // composer reads ~60px too tall). The observer's first callback comes after layout and sets + // the right one. observe() return { diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index 3816a18898..9660cca149 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -2,8 +2,8 @@ import type HomeSessionStarterComponent from '../sessions/HomeSessionStarter.svelte' // The starter is a full AIChat, which reaches monaco, and the build refuses monaco in Home's - // static imports. Kept once loaded, so a return to Home renders it in its first frame: the - // AI Sessions → Home view transition snapshots that frame. + // static imports. Kept once loaded, so a return to Home renders it in its first frame + // instead of a skeleton. let loadedStarter: typeof HomeSessionStarterComponent | undefined function loadStarter() { return import('../sessions/HomeSessionStarter.svelte').then((m) => (loadedStarter = m.default)) diff --git a/frontend/src/lib/components/sessions/HomeSessionStarter.svelte b/frontend/src/lib/components/sessions/HomeSessionStarter.svelte index 942025c21c..fd77de15bd 100644 --- a/frontend/src/lib/components/sessions/HomeSessionStarter.svelte +++ b/frontend/src/lib/components/sessions/HomeSessionStarter.svelte @@ -25,7 +25,7 @@ import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' import { useWorkModeLock } from './workModeLock.svelte' import { AIWorkMode } from '$lib/components/copilot/chat/workMode' - import { composerSentFromHome, takeComposerFocus } from './homeSessionsTransition.svelte' + import { takeComposerFocus } from './homeComposerFocus' import { homeComposerContext } from './homeComposerContext.svelte' // Home's block is the empty session's own chat, run by that session's manager: the @@ -68,7 +68,6 @@ // The turn is already running on the session's manager; the page it belongs to takes over. function onSent() { - composerSentFromHome() selectSession(sessionId) void goto(sessionPageHref(sessionId)) } @@ -148,7 +147,6 @@ suggestions: starterPrompts.suggestions, fewItems: starterPrompts.fewItems }} - viewTransitionNames {onSent} {actingOn} workModeLock={workModeLock.current} diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index fc128584ba..bedc7c9167 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -48,7 +48,6 @@ X } from 'lucide-svelte' import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' - import { arrivingFromHomeSend } from './homeSessionsTransition.svelte' import SessionChangesBar from './SessionChangesBar.svelte' import SessionShareButton from './SessionShareButton.svelte' import { clearSessionRecovered, isSessionRecovered } from './sessionRecoveryNotice.svelte' @@ -628,7 +627,6 @@ starter={greets ? { where: 'session', suggestions: starterPrompts.suggestions } : undefined} - viewTransitionNames={headerInPage && (opensSession || arrivingFromHomeSend())} emptyHint={sessionEmptyHint} {inputPreface} actingOn={hasFirstUserMessage ? undefined : actingOn} diff --git a/frontend/src/lib/components/sessions/homeSessionsTransition.test.ts b/frontend/src/lib/components/sessions/homeComposerFocus.test.ts similarity index 90% rename from frontend/src/lib/components/sessions/homeSessionsTransition.test.ts rename to frontend/src/lib/components/sessions/homeComposerFocus.test.ts index 4be3f18fce..1574381701 100644 --- a/frontend/src/lib/components/sessions/homeSessionsTransition.test.ts +++ b/frontend/src/lib/components/sessions/homeComposerFocus.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { composerFocusAfter } from './homeSessionsTransition.svelte' +import { composerFocusAfter } from './homeComposerFocus' describe('composerFocusAfter', () => { it('hands focus on only for the return from AI Sessions to Home', () => { diff --git a/frontend/src/lib/components/sessions/homeComposerFocus.ts b/frontend/src/lib/components/sessions/homeComposerFocus.ts new file mode 100644 index 0000000000..470c355b0c --- /dev/null +++ b/frontend/src/lib/components/sessions/homeComposerFocus.ts @@ -0,0 +1,38 @@ +import type { OnNavigate } from '@sveltejs/kit' + +// Leaving AI Sessions for Home with the composer focused hands that focus on to Home's +// composer, so typing carries on across the move. + +const HOME = '/' +const SESSIONS = '/sessions' + +/** Whether the composer had focus when the user left AI Sessions for Home. */ +let composerFocused = false + +/** The focus flag to keep after a navigation from `from` to `to`. Only the return to Home + * hands focus on — the sessions page focuses its composer on its own — and every other + * navigation clears the flag, so a later arrival on Home never acts on a stale one. */ +export function composerFocusAfter( + from: string | undefined, + to: string | undefined, + composerHadFocus: boolean +): boolean { + return from === SESSIONS && to === HOME && composerHadFocus +} + +/** Whether Home should give its composer the focus the sessions page's had. Read once: + * a later visit starts unfocused. */ +export function takeComposerFocus(): boolean { + const focused = composerFocused + composerFocused = false + return focused +} + +/** For SvelteKit's `onNavigate`: records whether the composer had focus as the page left. */ +export function recordComposerFocus(navigation: OnNavigate): void { + composerFocused = composerFocusAfter( + navigation.from?.url.pathname, + navigation.to?.url.pathname, + document.activeElement?.closest('[data-session-composer]') != null + ) +} diff --git a/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts b/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts deleted file mode 100644 index 74a72ec0ce..0000000000 --- a/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { OnNavigate } from '@sveltejs/kit' -import { sessionState } from './sessionState.svelte' - -// Moving between Home and AI Sessions keeps the composer in sight: the browser -// snapshots the page before and after the navigation and glides the element that -// carries the `composer` view-transition-name on both sides (AIChatDisplay names it); -// the rest of the page cross-fades. The timing lives in app.css, keyed on the -// `data-home-sessions-transition` attribute set here for the length of the move. - -const HOME = '/' -const SESSIONS = '/sessions' - -/** Whether the composer had focus when the user left AI Sessions for Home. */ -let composerFocused = false - -/** The focus flag to keep after a navigation from `from` to `to`. Only the return to Home - * hands focus on — the sessions page focuses its composer on its own — and every other - * navigation clears the flag, so a later arrival on Home never acts on a stale one. */ -export function composerFocusAfter( - from: string | undefined, - to: string | undefined, - composerHadFocus: boolean -): boolean { - return from === SESSIONS && to === HOME && composerHadFocus -} - -/** Whether Home should give its composer the focus the sessions page's had. Read once: - * a later visit starts unfocused. */ -export function takeComposerFocus(): boolean { - const focused = composerFocused - composerFocused = false - return focused -} - -// A session that has sent its first message has a composer of its own, not Home's draft, so -// a move between it and Home is not one composer moving: its page names no composer. The one -// exception is the send from Home itself, which lands on that session already under way. -let sentFromHome = $state(false) - -/** Called by Home's composer as its send hands over to the session page. */ -export function composerSentFromHome(): void { - sentFromHome = true -} - -/** Whether the session page arriving now came from a send on Home, so its composer is the - * one Home just sent from. */ -export function arrivingFromHomeSend(): boolean { - return sentFromHome -} - -/** For SvelteKit's `onNavigate`: runs a move between Home and AI Sessions as a view - * transition. Any other navigation, or a browser without the API, is left alone. */ -export function homeSessionsTransition(navigation: OnNavigate): Promise<void> | undefined { - const from = navigation.from?.url.pathname - const to = navigation.to?.url.pathname - composerFocused = composerFocusAfter( - from, - to, - document.activeElement?.closest('[data-session-composer]') != null - ) - const direction = - from === HOME && to === SESSIONS - ? 'enter' - : from === SESSIONS && to === HOME - ? 'leave' - : undefined - // Good for the navigation the send started, and only that one. - if (direction !== 'enter') sentFromHome = false - if (!direction || navigation.willUnload || !document.startViewTransition) return - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return - const root = document.documentElement - // To or from a session under way there is no composer to carry over: Home's would only - // fade out or in on its own, so the move drops every name and the page cross-fades as - // one. Leaving, the page says so; arriving, the session's record does (it has sent once - // it has a workspace), short of the send from Home that just started it. - const composerOnScreen = Array.from(document.querySelectorAll('[data-session-composer]')).some( - (el) => getComputedStyle(el).viewTransitionName === 'composer' - ) - const arrivingId = navigation.to?.url.searchParams.get('session') ?? sessionState.currentSessionId - const arrivingUnderWay = - !sentFromHome && !!sessionState.sessions.find((s) => s.id === arrivingId)?.workspace_id - const plain = direction === 'leave' ? !composerOnScreen : arrivingUnderWay - root.dataset.homeSessionsTransition = plain ? 'fade' : direction - return new Promise((resolve) => { - const transition = document.startViewTransition(async () => { - resolve() - await navigation.complete - }) - // Rejects when the navigation is cancelled or fails; the attribute goes either way. - const done = () => { - delete root.dataset.homeSessionsTransition - sentFromHome = false - } - void transition.finished.then(done, done) - }) -} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 6b79582958..a2170657e3 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -59,7 +59,7 @@ } from '$lib/stores' import CenteredModal from '$lib/components/CenteredModal.svelte' import { afterNavigate, beforeNavigate, onNavigate } from '$app/navigation' - import { homeSessionsTransition } from '$lib/components/sessions/homeSessionsTransition.svelte' + import { recordComposerFocus } from '$lib/components/sessions/homeComposerFocus' import { goto } from '$lib/navigation' import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte' import type UserSettings from '$lib/components/UserSettings.svelte' @@ -450,7 +450,7 @@ return kind ? { kind, path: route.itemPath } : undefined } - onNavigate(homeSessionsTransition) + onNavigate(recordComposerFocus) beforeNavigate((navigation) => { if (preserveMenuOnNextNav) { diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index d23f92de96..e066761069 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -99,11 +99,6 @@ // workspace: a remount would drop the list's own state and refetch what it is already loading. let pageEl: HTMLDivElement | undefined = $state(undefined) - // Back from AI Sessions the view transition (homeSessionsTransition.svelte.ts) carries the arrival, so - // the page skips its own fade-in. Decided once at mount: gating the class on the attribute - // instead would start the fade from transparent the moment the transition removes it. - const fadeIn = - typeof document === 'undefined' || !document.documentElement.dataset.homeSessionsTransition const reducedMotion = useReducedMotion() let animatedWorkspace = $workspaceStore $effect(() => { @@ -292,7 +287,7 @@ <div bind:this={pageEl} - class="{fadeIn ? 'wm-page-in' : ''} flex flex-col w-full h-full overflow-y-auto items-center" + class="wm-page-in flex flex-col w-full h-full overflow-y-auto items-center" style="scrollbar-gutter: stable both-edges;" > <!-- Where edits to this workspace are made is a fact about the workspace, so it sits in the From 4a34dc69f544460e7893b04dbfd002f1c67125ae Mon Sep 17 00:00:00 2001 From: Guilhem <guilhemlemouel@gmail.com> Date: Tue, 27 Oct 2026 00:43:44 +0100 Subject: [PATCH 12/14] feat: show the draft badge on Home's favorite and recent cards (#12018) * feat: show the draft badge on Home's favorite and recent cards Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: show the draft badge on Home's agent cards Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../components/home/HomeQuickAccess.svelte | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/home/HomeQuickAccess.svelte b/frontend/src/lib/components/home/HomeQuickAccess.svelte index b81f848838..7b181eb4d3 100644 --- a/frontend/src/lib/components/home/HomeQuickAccess.svelte +++ b/frontend/src/lib/components/home/HomeQuickAccess.svelte @@ -18,11 +18,13 @@ import { Button } from '$lib/components/common' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { useEditRights } from '$lib/operatorWriteRights' - import { roleCapabilities } from '$lib/stores' + import { roleCapabilities, userStore } from '$lib/stores' import { AppService } from '$lib/gen' import { copyToClipboard, type Item } from '$lib/utils' import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' import Star from '$lib/components/Star.svelte' + import { useWorkspaceDrafts, type DraftItem } from '$lib/workspaceDrafts.svelte' import TimeAgo from '$lib/components/TimeAgo.svelte' import { favoriteManager, @@ -213,6 +215,27 @@ via: r.via })) }) + // The cards know only kind and path, so their drafts come from the workspace's draft list, + // keyed the way favorites are: a raw app's card can be an `app` one, its draft is `raw_app`. + // An agent is a resource and drafts as one; no other resource can sit at its path. + // The list holds the user's own drafts and legacy ones, so an item only another user drafted + // shows its badge in the list below but not on its card. + const drafts = useWorkspaceDrafts(() => workspace) + function cardKind(kind: DraftItem['kind']): FavoriteKind | undefined { + if (kind === 'script' || kind === 'flow' || kind === 'app' || kind === 'raw_app') { + return asStarKind(kind) + } + return kind === 'resource' ? 'agent' : undefined + } + let draftByItem = $derived( + new Map<string, DraftItem>( + drafts.items.flatMap((d) => { + const kind = cardKind(d.kind) + return kind ? [[`${kind}/${d.path}`, d]] : [] + }) + ) + ) + // Grows by a page from the "Load next" card; back to one page on the next visit. let recentLimit = $state(RECENT_COUNT) let recentMore = $derived(Math.max(0, recentAll.length - recentLimit)) @@ -334,6 +357,7 @@ <svelte:window bind:innerWidth /> {#snippet card(item: Entry, favorite: boolean)} + {@const draft = draftByItem.get(`${item.starKind}/${item.path}`)} <div class="group flex items-center gap-1 rounded-md border border-light bg-surface-tertiary hover:bg-surface-hover transition-colors min-w-0" > @@ -363,6 +387,21 @@ tabindex="-1" aria-hidden="true" ></a> + <!-- After the filler, so the badges line up down the column whatever the title's length. --> + {#if draft} + <span class="shrink-0 inline-flex"> + <DraftBadge + is_draft={draft.mine && !draft.legacy_draft} + draft_only={draft.draft_only} + draft_users={draft.draft_users} + currentUsername={$userStore?.username} + {workspace} + itemKind={draft.kind} + path={draft.path} + onMigrated={drafts.refresh} + /> + </span> + {/if} {#if !favorite && item.at} <span class="shrink-0 text-2xs text-secondary" From 0249bb94ac2c7cfd3f3d8943e525056927d77b27 Mon Sep 17 00:00:00 2001 From: Guilhem <guilhemlemouel@gmail.com> Date: Tue, 27 Oct 2026 01:24:09 +0100 Subject: [PATCH 13/14] refactor: harmonize flow and script settings layout (#12013) * refactor: harmonize flow and script settings layout Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep the priority input open while it is edited Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep settings rows open while edited and name the deploying user for non-admins Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: write a setting before its row opens and show its real state while edited Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: slide settings rows open to their full height Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: remount a settings row's fields when its toggle turns it on Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: put the AI prompt behind a settings toggle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: summarize active runtime and permission settings on the metadata tab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: move the active settings chips into the settings header Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: bring back the active setting icons on the settings tabs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev> --- frontend/src/lib/assets/app.css | 10 +- .../components/JobTokenScopesSetting.svelte | 99 +- .../components/ScriptAdvancedSettings.svelte | 283 ----- .../src/lib/components/ScriptBuilder.svelte | 998 +++++----------- .../components/copilot/AIFormSettings.svelte | 16 +- .../details/ErrorHandlerToggleButtonV2.svelte | 80 -- .../lib/components/flows/DebounceLimit.svelte | 176 ++- .../flows/content/FlowEditorPanel.svelte | 2 +- .../flows/content/FlowModuleDebounce.svelte | 3 - .../flows/content/FlowRunSettings.svelte | 89 +- .../flows/content/FlowSettings.svelte | 1041 ++++++----------- .../flows/content/ScriptEditorDrawer.svelte | 8 +- .../WorkspaceScriptSettingsDrawer.svelte | 12 +- .../itemSettings/ActiveSettingChips.svelte | 73 ++ .../itemSettings/CacheSetting.svelte | 38 + .../ConcurrencyLimitSetting.svelte | 71 ++ .../DedicatedWorkersSetting.svelte | 41 + .../itemSettings/DeleteAfterUseSetting.svelte | 29 + .../itemSettings/InvisibleRunsSetting.svelte | 19 + .../MuteErrorHandlerSetting.svelte | 43 + .../itemSettings/PrioritySetting.svelte | 53 + .../itemSettings/RunOnBehalfOfSetting.svelte | 131 +++ .../itemSettings/ScriptRuntimeSettings.svelte | 140 +++ .../itemSettings/SettingGroup.svelte | 21 + .../itemSettings/SettingIcons.svelte | 27 + .../itemSettings/SettingToggle.svelte | 90 ++ .../itemSettings/WorkerTagSetting.svelte | 57 + .../itemSettings/onBehalfOf.test.ts | 25 + .../lib/components/itemSettings/onBehalfOf.ts | 43 + .../src/lib/components/scriptSettings.test.ts | 2 +- frontend/src/lib/components/scriptSettings.ts | 220 +++- 31 files changed, 1932 insertions(+), 2008 deletions(-) delete mode 100644 frontend/src/lib/components/ScriptAdvancedSettings.svelte delete mode 100644 frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte create mode 100644 frontend/src/lib/components/itemSettings/ActiveSettingChips.svelte create mode 100644 frontend/src/lib/components/itemSettings/CacheSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/ConcurrencyLimitSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/DedicatedWorkersSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/DeleteAfterUseSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/InvisibleRunsSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/MuteErrorHandlerSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/PrioritySetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/RunOnBehalfOfSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/ScriptRuntimeSettings.svelte create mode 100644 frontend/src/lib/components/itemSettings/SettingGroup.svelte create mode 100644 frontend/src/lib/components/itemSettings/SettingIcons.svelte create mode 100644 frontend/src/lib/components/itemSettings/SettingToggle.svelte create mode 100644 frontend/src/lib/components/itemSettings/WorkerTagSetting.svelte create mode 100644 frontend/src/lib/components/itemSettings/onBehalfOf.test.ts create mode 100644 frontend/src/lib/components/itemSettings/onBehalfOf.ts diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 4217c95831..705c22371e 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -414,12 +414,18 @@ input[type='time']::-webkit-calendar-picker-indicator { } /* Settings search highlight (used by both the drawer and the setup page) */ -[data-setting-key] { +[data-setting-key], +[data-setting] { transition: outline 0.8s ease; outline: 2px solid transparent; outline-offset: -2px; } -[data-setting-key].setting-highlight { +[data-setting].setting-highlight { + outline-offset: 4px; + border-radius: 4px; +} +[data-setting-key].setting-highlight, +[data-setting].setting-highlight { outline: 2px solid rgb(var(--color-border-accent, 59 130 246)); } diff --git a/frontend/src/lib/components/JobTokenScopesSetting.svelte b/frontend/src/lib/components/JobTokenScopesSetting.svelte index 6ef1ecf3c4..d36888982e 100644 --- a/frontend/src/lib/components/JobTokenScopesSetting.svelte +++ b/frontend/src/lib/components/JobTokenScopesSetting.svelte @@ -1,19 +1,16 @@ <script lang="ts"> - import Toggle from './Toggle.svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import ScopeSelector from './settings/ScopeSelector.svelte' + import SettingToggle from './itemSettings/SettingToggle.svelte' interface Props { /** `null`/`undefined`: the job token has the full permissions of the identity it runs as. */ value: string[] | null | undefined kind: 'script' | 'flow' | 'step' - /** Match the toggles listed around it. */ - size?: 'xs' | 'sm' - textClass?: string } - let { value = $bindable(), kind, size = 'sm', textClass }: Props = $props() + let { value = $bindable(), kind }: Props = $props() const OIDC_ONLY = ['oidc:write'] @@ -32,58 +29,48 @@ function setMode(next: Mode) { customPicked = next === 'custom' - value = - next === 'oidc_only' ? [...OIDC_ONLY] : next === 'no_api' ? [] : [...(value ?? [])] + value = next === 'oidc_only' ? [...OIDC_ONLY] : next === 'no_api' ? [] : [...(value ?? [])] } </script> -<div class="flex flex-col gap-2"> - <Toggle - {size} - {textClass} - checked={value != null} - on:change={() => { - if (value != null) { - value = null - } else { - setMode('oidc_only') - } - }} - options={{ - right: 'Restrict the job token', - rightTooltip: - "Limit what the WM_TOKEN of this " + - kind + - "'s jobs can do through the API, e.g. only mint an OIDC token. Use it for code that handles untrusted input, such as AI agents." - }} - /> - {#if value != null} - <ToggleButtonGroup selected={mode} onSelected={(v) => setMode(v)}> - {#snippet children({ item })} - <ToggleButton value="oidc_only" label="OIDC token only" {item} /> - <ToggleButton value="no_api" label="No API access" {item} /> - <ToggleButton value="custom" label="Custom" {item} /> - {/snippet} - </ToggleButtonGroup> - {#if mode === 'custom'} - <ScopeSelector - bind:selectedScopes={() => value ?? [], (scopes) => (value = [...scopes])} - emptyLabel="No scopes selected. The job token can only reach its own job." - /> - {/if} - <p class="text-2xs text-secondary"> - {#if kind === 'step'} - The <code>WM_TOKEN</code> of every job this step runs can only do what these scopes allow, on top - of the flow's own restriction. Loop and branch bodies, and AI agent tools, inherit it. - {:else} - The <code>WM_TOKEN</code> of every job of this {kind} can only do what these scopes allow. Jobs - it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction. - {/if} - Variables and resources passed as <code>$var:</code>/<code>$res:</code> inputs, an AI agent's provider - resource, relative imports and object storage each need a read scope, and script state - (<code>getState</code>/<code>setState</code>) needs <code>resources:write</code>. Write scopes on scripts, - flows, schedules or triggers let a job escape the restriction. Jobs with a restricted token never - run on dedicated workers. - </p> +<SettingToggle + setting="job-token" + label="Restrict the job token" + tooltip={`Limit what the WM_TOKEN of this ${kind}'s jobs can do through the API, e.g. only mint an OIDC token. Use it for code that handles untrusted input, such as AI agents.`} + checked={value != null} + onchange={(on) => { + if (on) { + setMode('oidc_only') + } else { + value = null + } + }} +> + <ToggleButtonGroup selected={mode} onSelected={(v) => setMode(v)}> + {#snippet children({ item })} + <ToggleButton value="oidc_only" label="OIDC token only" {item} /> + <ToggleButton value="no_api" label="No API access" {item} /> + <ToggleButton value="custom" label="Custom" {item} /> + {/snippet} + </ToggleButtonGroup> + {#if mode === 'custom'} + <ScopeSelector + bind:selectedScopes={() => value ?? [], (scopes) => (value = [...scopes])} + emptyLabel="No scopes selected. The job token can only reach its own job." + /> {/if} -</div> + <p class="text-2xs text-secondary"> + {#if kind === 'step'} + The <code>WM_TOKEN</code> of every job this step runs can only do what these scopes allow, on top + of the flow's own restriction. Loop and branch bodies, and AI agent tools, inherit it. + {:else} + The <code>WM_TOKEN</code> of every job of this {kind} can only do what these scopes allow. Jobs + it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction. + {/if} + Variables and resources passed as <code>$var:</code>/<code>$res:</code> inputs, an AI agent's + provider resource, relative imports and object storage each need a read scope, and script state + (<code>getState</code>/<code>setState</code>) needs <code>resources:write</code>. Write scopes + on scripts, flows, schedules or triggers let a job escape the restriction. Jobs with a + restricted token never run on dedicated workers. + </p> +</SettingToggle> diff --git a/frontend/src/lib/components/ScriptAdvancedSettings.svelte b/frontend/src/lib/components/ScriptAdvancedSettings.svelte deleted file mode 100644 index a42c2e8909..0000000000 --- a/frontend/src/lib/components/ScriptAdvancedSettings.svelte +++ /dev/null @@ -1,283 +0,0 @@ -<script lang="ts"> - import { createBubbler } from 'svelte/legacy' - import Section from '$lib/components/Section.svelte' - import Label from '$lib/components/Label.svelte' - import Toggle from '$lib/components/Toggle.svelte' - import Tooltip from '$lib/components/Tooltip.svelte' - import Alert from '$lib/components/common/alert/Alert.svelte' - import { SecondsInput } from '$lib/components/common' - import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte' - import DebounceLimit from '$lib/components/flows/DebounceLimit.svelte' - import { enterpriseLicense } from '$lib/stores' - import { isCloudHosted } from '$lib/cloud' - import type { Schema } from '$lib/common' - import type { ScriptAdvancedSettingsFields } from './scriptSettings' - - const bubble = createBubbler() - - interface Props { - script: ScriptAdvancedSettingsFields - // Workspace to read worker tags from (defaults to $workspaceStore inside the picker). - workspaceId?: string | undefined - } - - let { script = $bindable(), workspaceId = undefined }: Props = $props() -</script> - -<div class="flex flex-col gap-8"> - <Section label="Worker group tag (queue)"> - {#snippet header()} - <Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups"> - The script will be executed on a worker configured to listen to this worker group tag - (queue). For instance, you could setup an "highmem", or "gpu" tag. - </Tooltip> - {/snippet} - <WorkerTagPicker bind:tag={script.tag} {workspaceId} /> - </Section> - - <Section label="Concurrency limits" eeOnly> - {#snippet header()} - <Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits"> - Allowed concurrency within a given timeframe - </Tooltip> - {/snippet} - <Toggle - size="sm" - checked={Boolean(script.concurrent_limit)} - on:change={() => { - if (script.concurrent_limit && script.concurrent_limit != undefined) { - script.concurrent_limit = undefined - script.concurrency_time_window_s = undefined - script.concurrency_key = undefined - } else { - script.concurrent_limit = 1 - } - }} - options={{ right: 'Concurrency limits' }} - /> - {#if Boolean(script.concurrent_limit)} - <div class="flex flex-col gap-4 mt-2"> - <Label label="Max number of executions within the time window"> - <div class="flex flex-row gap-2 max-w-sm whitespace-nowrap"> - <input - disabled={!$enterpriseLicense} - bind:value={script.concurrent_limit} - type="number" - /> - </div> - </Label> - <Label label="Time window in seconds"> - <SecondsInput - disabled={!$enterpriseLicense} - bind:seconds={script.concurrency_time_window_s} - /> - </Label> - <Label label="Custom concurrency key (optional)"> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits#custom-concurrency-key" - > - Concurrency keys are global, you can have them be workspace specific using the - variable `$workspace`. You can also use an argument's value using `$args[name_of_arg]`</Tooltip - > - {/snippet} - <input - disabled={!$enterpriseLicense} - type="text" - bind:value={script.concurrency_key} - placeholder={`$workspace/script/${script.path ?? ''}-$args[foo]`} - /> - </Label> - </div> - {/if} - </Section> - - <Section label="Cache"> - {#snippet header()} - <Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/caching"> - Cache the results for each possible inputs - </Tooltip> - {/snippet} - <div class="flex gap-2 shrink flex-col"> - <Toggle - size="sm" - bind:checked={() => !!script.cache_ttl, (v) => (script.cache_ttl = v ? 300 : undefined)} - options={{ right: 'Cache the results for each possible inputs' }} - /> - {#if script.cache_ttl} - <div class="text-2xs text-secondary">How long to keep the cache valid</div> - <SecondsInput bind:seconds={script.cache_ttl} /> - <Toggle - size="2xs" - bind:checked={ - () => script.cache_ignore_s3_path, (v) => (script.cache_ignore_s3_path = v || undefined) - } - options={{ - right: 'Ignore S3 Object paths for caching purposes', - rightTooltip: - 'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.' - }} - /> - {/if} - </div> - </Section> - - <Section label="Timeout"> - {#snippet header()} - <Tooltip documentationLink="https://www.windmill.dev/docs/script_editor/settings#timeout"> - Add a custom timeout for this script - </Tooltip> - {/snippet} - <div class="flex gap-2 shrink flex-col"> - <Toggle - size="sm" - checked={Boolean(script.timeout)} - on:change={() => { - if (script.timeout && script.timeout != undefined) { - script.timeout = undefined - } else { - script.timeout = 300 - } - }} - options={{ right: 'Add a custom timeout for this script' }} - /> - {#if Boolean(script.timeout)} - <span class="text-xs font-semibold text-emphasis leading-none mt-2">Timeout duration</span> - <SecondsInput bind:seconds={script.timeout} /> - {/if} - </div> - </Section> - - <Section label="Debouncing"> - {#snippet header()} - <Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/job_debouncing"> - Debounce Jobs - </Tooltip> - {/snippet} - <DebounceLimit - size="sm" - bind:debounce_delay_s={script.debounce_delay_s} - bind:debounce_key={script.debounce_key} - bind:debounce_args_to_accumulate={script.debounce_args_to_accumulate} - bind:max_total_debouncing_time={script.max_total_debouncing_time} - bind:max_total_debounces_amount={script.max_total_debounces_amount} - schema={script.schema as Schema} - placeholder={`$workspace/script/${script.path ?? ''}-$args[foo]`} - /> - </Section> - - <Section label="Perpetual script"> - {#snippet header()} - <Tooltip documentationLink="https://www.windmill.dev/docs/script_editor/perpetual_scripts"> - Restart the script upon ending unless cancelled - </Tooltip> - {/snippet} - <Toggle - size="sm" - checked={Boolean(script.restart_unless_cancelled)} - on:change={() => { - script.restart_unless_cancelled = script.restart_unless_cancelled ? undefined : true - }} - options={{ right: 'Restart upon ending unless cancelled' }} - /> - </Section> - - <Section label="Dedicated workers" eeOnly> - {#snippet header()} - <Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/dedicated_workers"> - In this mode, the script is meant to be run on dedicated workers that run the script at - native speed. Can reach >1500rps per dedicated worker. Only available on enterprise - edition and for Python3, Deno, Bun and Bunnative. - </Tooltip> - {/snippet} - <Toggle - disabled={!$enterpriseLicense || - isCloudHosted() || - (script.language != 'bun' && - script.language != 'bunnative' && - script.language != 'python3' && - script.language != 'deno')} - size="sm" - checked={Boolean(script.dedicated_worker)} - on:change={() => { - script.dedicated_worker = script.dedicated_worker ? undefined : true - }} - options={{ right: 'Script is run on dedicated workers' }} - /> - {#if script.dedicated_worker} - <div class="py-2"> - <Alert type="info" title="Require dedicated workers"> - A worker group needs to be configured to listen to this script. Select it in the dedicated - workers section of the worker group configuration. - </Alert> - </div> - {/if} - </Section> - - <Section label="Delete after completion" eeOnly> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/script_editor/settings#delete-after-use" - > - The logs, arguments and results of the job will be completely deleted from Windmill after - the specified delay once it is complete. Set to 0 for immediate deletion. The deletion is - irreversible. This settings ONLY applies when the script is used within a flow or triggered - synchronously. - </Tooltip> - {/snippet} - <div class="flex gap-2 shrink flex-col"> - <Toggle - disabled={!$enterpriseLicense} - size="sm" - checked={script.delete_after_secs != null} - on:change={() => { - script.delete_after_secs = script.delete_after_secs != null ? undefined : 0 - }} - options={{ right: 'Delete logs, arguments and results after completion' }} - /> - {#if script.delete_after_secs != null} - <SecondsInput bind:seconds={script.delete_after_secs} disabled={!$enterpriseLicense} /> - {/if} - </div> - </Section> - - {#if !isCloudHosted()} - <Section label="High priority script" eeOnly> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/jobs#high-priority-jobs" - > - Jobs from script labeled as high priority take precedence over the other jobs when in the - jobs queue. - {#if !$enterpriseLicense}This is a feature only available on enterprise edition.{/if} - </Tooltip> - {/snippet} - <Toggle - disabled={!$enterpriseLicense || isCloudHosted()} - size="sm" - checked={script.priority !== undefined && script.priority > 0} - on:change={() => { - script.priority = script.priority ? undefined : 100 - }} - options={{ right: 'Label as high priority' }} - > - {#snippet right()} - <input - type="number" - class="!w-16 ml-4" - disabled={script.priority === undefined} - bind:value={script.priority} - onfocus={bubble('focus')} - onchange={() => { - if (script.priority && script.priority > 100) { - script.priority = 100 - } else if (script.priority && script.priority < 0) { - script.priority = 0 - } - }} - /> - {/snippet} - </Toggle> - </Section> - {/if} -</div> diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 7c6db7b242..0e7b25fc99 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -1,7 +1,4 @@ <script lang="ts"> - import { createBubbler } from 'svelte/legacy' - - const bubble = createBubbler() import { ScriptService, type Script, @@ -54,14 +51,13 @@ import ScriptEditor from './ScriptEditor.svelte' import DbtEditor from './dbt/DbtEditor.svelte' import { findModulePathClash } from './scriptModulePath' - import { Alert, Button, Drawer, SecondsInput, Tab, TabContent, Tabs } from './common' + import { Alert, Button, Drawer, Tab, TabContent, Tabs } from './common' import LanguageIcon from './common/languageIcons/LanguageIcon.svelte' - import type { SupportedLanguage, Schema } from '$lib/common' + import type { SupportedLanguage } from '$lib/common' import Tooltip from './Tooltip.svelte' import DrawerContent from './common/drawer/DrawerContent.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import ErrorHandlerToggleButton from '$lib/components/details/ErrorHandlerToggleButton.svelte' import { Bug, CheckCircle, @@ -69,7 +65,6 @@ DiffIcon, EllipsisVertical, Network, - Plus, Rocket, Settings, Shuffle, @@ -83,9 +78,7 @@ import { sendUserToast } from '$lib/toast' import { isCloudHosted } from '$lib/cloud' import Awareness from './Awareness.svelte' - import { fade } from 'svelte/transition' import Popover from './Popover.svelte' - import Toggle from './Toggle.svelte' import JobTokenScopesSetting from './JobTokenScopesSetting.svelte' import ScriptSchema from './ScriptSchema.svelte' import Section from './Section.svelte' @@ -125,9 +118,17 @@ import type { ScriptBuilderProps } from './script_builder' import WorkerTagSelect from './WorkerTagSelect.svelte' import type { ButtonType } from './common/button/model' - import DebounceLimit from './flows/DebounceLimit.svelte' import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork' - import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' + import type { OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' + import RunOnBehalfOfSetting from './itemSettings/RunOnBehalfOfSetting.svelte' + import MuteErrorHandlerSetting from './itemSettings/MuteErrorHandlerSetting.svelte' + import SettingGroup from './itemSettings/SettingGroup.svelte' + import { keepsDeployedOnBehalfOf } from './itemSettings/onBehalfOf' + import ScriptRuntimeSettings from './itemSettings/ScriptRuntimeSettings.svelte' + import InvisibleRunsSetting from './itemSettings/InvisibleRunsSetting.svelte' + import SettingIcons from './itemSettings/SettingIcons.svelte' + import ActiveSettingChips, { revealSetting } from './itemSettings/ActiveSettingChips.svelte' + import { getActivePermissionBadges, getActiveScriptSettingsBadges } from './scriptSettings' import WacExportDrawer from './scripts/WacExportDrawer.svelte' import { UserDraft } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' @@ -279,15 +280,23 @@ let selectedInputTab: 'main' | 'preprocessor' | 'diagram' = $state('main') let hasPreprocessor = $state(false) let preserveOnBehalfOf = $state(false) - - const WM_DEPLOYERS_GROUP = 'wm_deployers' - let isDeployer = $derived(actingUser?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) - let canPreserve = $derived(!!actingUser?.is_admin || !!actingUser?.is_super_admin || isDeployer) - let originalOnBehalfOfEmail = $derived(savedScript?.on_behalf_of_email) - let originalOnBehalfOfPermissionedAs = $derived(savedScript?.on_behalf_of) let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined) - let customOnBehalfOfEmail: string = $state('') - let myPermissionedAs = $derived($userStore?.username ? `u/${$userStore.username}` : undefined) + function keepDeployedOnBehalfOf() { + if ( + !keepsDeployedOnBehalfOf( + actingUser, + onBehalfOfChoice, + script.on_behalf_of_email, + savedScript?.on_behalf_of_email + ) + ) + return + onBehalfOfChoice = 'target' + script.on_behalf_of_email = savedScript?.on_behalf_of_email + script.on_behalf_of = savedScript?.on_behalf_of + preserveOnBehalfOf = true + } + const isNewScript = $derived(initialPath == '' || savedScript?.no_deployed === true) let metadataOpen = $state( !untrack(() => neverShowMeta) && @@ -315,6 +324,7 @@ [ ['metadata', customUi?.settingsPanel?.disableMetadata], ['runtime', customUi?.settingsPanel?.disableRuntime], + ['permissions', customUi?.settingsPanel?.disableRuntime], ['triggers', customUi?.settingsPanel?.disableTriggers] ] as const ).find(([, disabled]) => disabled !== true)?.[0] @@ -1186,7 +1196,7 @@ // (empty initialPath) but with a path the AI already picked. let dirtyPath = $state(initialPathChosen) - let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state( + let selectedTab: 'metadata' | 'runtime' | 'permissions' | 'ui' | 'triggers' = $state( (() => { if (customUi?.settingsPanel?.disableMetadata !== true) { // first option: either no custom UI or metadata is enabled @@ -1205,6 +1215,15 @@ })() ) + let runtimeBadges = $derived(getActiveScriptSettingsBadges(script)) + let permissionBadges = $derived(getActivePermissionBadges(script)) + let settingsContent: HTMLElement | undefined = $state(undefined) + async function openSetting(tab: string, setting: string) { + selectedTab = tab as typeof selectedTab + await tick() + revealSetting(settingsContent, setting) + } + setContext('disableTooltips', untrack(() => customUi)?.disableTooltips === true) function langToLanguage(lang: SupportedLanguage | 'docker' | 'bunnative'): SupportedLanguage { @@ -1380,6 +1399,18 @@ <svelte:window onkeydown={onKeyDown} /> {@render children?.()} +{#snippet muteSetting()} + {#if customUi?.settingsPanel?.metadata?.disableMute !== true} + <SettingGroup label="Error handling" class="flex flex-col gap-6"> + <MuteErrorHandlerSetting + kind="script" + path={script.path} + bind:muted={script.ws_error_handler_muted} + /> + </SettingGroup> + {/if} +{/snippet} + <DeployOverrideConfirmationModal {deployedBy} {confirmCallback} @@ -1419,6 +1450,17 @@ aiId="script-builder-settings" aiDescription="Script builder settings" > + {#snippet titleExtra()} + {#if customUi?.settingsPanel?.disableRuntime !== true} + <ActiveSettingChips + groups={[ + { tab: 'runtime', badges: runtimeBadges }, + { tab: 'permissions', badges: permissionBadges } + ]} + onopen={openSetting} + /> + {/if} + {/snippet} <!-- svelte-ignore a11y_autofocus --> <div class="flex flex-col h-full"> <Tabs bind:selected={selectedTab} wrapperClass="flex-none w-full"> @@ -1436,7 +1478,21 @@ aiId="script-builder-runtime" aiDescription="Runtime settings" label="Runtime" - /> + > + {#snippet extra()} + <SettingIcons badges={runtimeBadges} /> + {/snippet} + </Tab> + <Tab + value="permissions" + aiId="script-builder-permissions" + aiDescription="Permissions settings: run on behalf of, runs visibility, job token" + label="Permissions" + > + {#snippet extra()} + <SettingIcons badges={permissionBadges} /> + {/snippet} + </Tab> {/if} <!-- Not for dbt: its run form is derived from the descriptor server-side (`dbt_arg_schema`), so anything refined here is overwritten by the @@ -1474,88 +1530,80 @@ {/if} {#snippet content()} - <div class="min-h-0 grow overflow-y-auto"> + <div class="min-h-0 grow overflow-y-auto" bind:this={settingsContent}> <TabContent value="metadata"> - <div class="flex flex-col gap-8 px-4 py-2 pb-12"> - <Section label="Metadata"> - {#snippet action()} - {#if customUi?.settingsPanel?.metadata?.disableMute !== true} - <div class="flex flex-row items-center gap-2"> - <ErrorHandlerToggleButton - kind="script" - scriptOrFlowPath={script.path} - bind:errorHandlerMuted={script.ws_error_handler_muted} - iconOnly={false} - /> - </div> + <div class="flex flex-col gap-8 p-4 pb-8"> + <div class="flex flex-col gap-6"> + <Label label="Summary"> + <MetadataGen + aiId="create-script-summary-input" + aiDescription="Summary / Title of the new script" + bind:content={script.summary} + code={script.content} + promptConfigName="summary" + generateOnAppear + onChange={() => onSummaryChange(script.summary)} + elementProps={{ + type: 'text', + placeholder: 'Short summary to be displayed when listed' + }} + /> + </Label> + <LabelsInput bind:labels={script.labels} class="-mt-4" /> + <Label label="Path"> + {#snippet header()} + <Tooltip + documentationLink="https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path" + > + The unique identifier of the script in the workspace that defines + permissions + </Tooltip> + {/snippet} + <Path + bind:this={path} + bind:error={pathError} + bind:path={script.path} + bind:dirty={dirtyPath} + {initialPath} + autofocus={false} + namePlaceholder="script" + kind="script" + workspaceOverride={opWorkspace} + /> + {#if initialPath && script.path && script.path !== initialPath} + <Alert + type="info" + size="xs" + title="Deploy the script to make the path change effective." + class="mt-2" + /> {/if} - {/snippet} - <div class="flex flex-col gap-6"> - <Label label="Summary"> - <MetadataGen - aiId="create-script-summary-input" - aiDescription="Summary / Title of the new script" - bind:content={script.summary} - code={script.content} - promptConfigName="summary" - generateOnAppear - onChange={() => onSummaryChange(script.summary)} - elementProps={{ - type: 'text', - placeholder: 'Short summary to be displayed when listed' - }} - /> - </Label> - <LabelsInput bind:labels={script.labels} class="-mt-4" /> - <Label label="Path"> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path" - > - The unique identifier of the script in the workspace that defines - permissions - </Tooltip> - {/snippet} - <Path - bind:this={path} - bind:error={pathError} - bind:path={script.path} - bind:dirty={dirtyPath} - {initialPath} - autofocus={false} - namePlaceholder="script" - kind="script" - workspaceOverride={opWorkspace} - /> - {#if initialPath && script.path && script.path !== initialPath} - <Alert - type="info" - size="xs" - title="Deploy the script to make the path change effective." - class="mt-2" - /> - {/if} - </Label> - <Label label="Description"> - <MetadataGen - bind:content={script.description} - code={script.content} - promptConfigName="description" - elementType="textarea" - elementProps={{ - placeholder: 'What this script does and how to use it' - }} - /> - </Label> - </div> - </Section> + </Label> + <Label label="Description"> + <MetadataGen + bind:content={script.description} + code={script.content} + promptConfigName="description" + elementType="textarea" + elementProps={{ + placeholder: 'What this script does and how to use it' + }} + /> + </Label> + {#if script.schema && !disableAi && !customUi?.settingsPanel?.metadata?.disableAiFilling} + <AIFormSettings + bind:prompt={script.schema.prompt_for_ai as string | undefined} + type="script" + /> + {/if} + </div> {#if !customUi?.settingsPanel?.metadata?.languages || customUi?.settingsPanel?.metadata?.languages?.length > 1} <Section label="Language"> {#snippet action()} <DefaultScripts /> {/snippet} {#if lockedLanguage} - <div class="text-sm text-primary italic mb-2"> + <div class="text-xs text-secondary mb-2"> As a forked script, the language '{script.language}' cannot be modified. </div> {/if} @@ -1601,123 +1649,118 @@ </Popover> {/each} </div> + <div class="flex items-center gap-2 mt-4 flex-wrap"> + <span class="text-2xs text-secondary">Template</span> + <Button + unifiedSize="xs" + variant="default" + startIcon={{ + icon: LanguageIcon, + props: { lang: 'claudesandbox', width: 16, height: 16 } + } as ButtonType.Icon} + onclick={() => { + template = 'claudesandbox' + script.language = 'bun' + initContent('bun', script.kind, template) + }} + > + Claude Sandbox + </Button> + <Button + unifiedSize="xs" + variant="default" + startIcon={{ + icon: LanguageIcon, + props: { lang: 'pisandbox', width: 16, height: 16 } + } as ButtonType.Icon} + onclick={() => { + template = 'pisandbox' + script.language = 'bun' + initContent('bun', script.kind, template) + }} + > + Pi Sandbox + </Button> + <span title="Workflow-as-Code"> + <Button + unifiedSize="xs" + variant="default" + startIcon={{ + icon: LanguageIcon, + props: { lang: 'bun', width: 16, height: 16 } + } as ButtonType.Icon} + onclick={() => { + template = 'wac_typescript' + script.language = 'bun' + script.modules = { + 'helper.ts': { + content: + 'export function main(a: string): string {\n return `hello ${a}`\n}\n', + language: 'bun' + } + } + initContent('bun', script.kind, template) + }} + > + WAC TypeScript + </Button> + </span> + <span title="Workflow-as-Code"> + <Button + unifiedSize="xs" + variant="default" + startIcon={{ + icon: LanguageIcon, + props: { lang: 'python3', width: 16, height: 16 } + } as ButtonType.Icon} + onclick={() => { + template = 'wac_python' + script.language = 'python3' + script.modules = { + 'helper.py': { + content: 'def main(a: str) -> str:\n return f"hello {a}"\n', + language: 'python3' + } + } + initContent('python3', script.kind, template) + }} + > + WAC Python + </Button> + </span> + <Button + unifiedSize="xs" + variant="default" + startIcon={{ + icon: LanguageIcon, + props: { lang: 'bun', width: 16, height: 16 } + } as ButtonType.Icon} + onclick={() => { + template = 'ci_test_bun' + script.language = 'bun' + initContent('bun', script.kind, template) + }} + > + CI Test TypeScript + </Button> + <Button + unifiedSize="xs" + variant="default" + startIcon={{ + icon: LanguageIcon, + props: { lang: 'python3', width: 16, height: 16 } + } as ButtonType.Icon} + onclick={() => { + template = 'ci_test_python' + script.language = 'python3' + initContent('python3', script.kind, template) + }} + > + CI Test Python + </Button> + </div> </Section> {/if} - <div class="flex items-center gap-2 mt-2 flex-wrap"> - <span class="text-2xs text-secondary">Template</span> - <Button - size="xs2" - variant="border" - color="light" - startIcon={{ - icon: LanguageIcon, - props: { lang: 'claudesandbox', width: 16, height: 16 } - } as ButtonType.Icon} - on:click={() => { - template = 'claudesandbox' - script.language = 'bun' - initContent('bun', script.kind, template) - }} - > - Claude Sandbox - </Button> - <Button - unifiedSize="xs" - variant="default" - startIcon={{ - icon: LanguageIcon, - props: { lang: 'pisandbox', width: 16, height: 16 } - } as ButtonType.Icon} - onclick={() => { - template = 'pisandbox' - script.language = 'bun' - initContent('bun', script.kind, template) - }} - > - Pi Sandbox - </Button> - <span title="Workflow-as-Code"> - <Button - size="xs2" - variant="border" - color="light" - startIcon={{ - icon: LanguageIcon, - props: { lang: 'bun', width: 16, height: 16 } - } as ButtonType.Icon} - on:click={() => { - template = 'wac_typescript' - script.language = 'bun' - script.modules = { - 'helper.ts': { - content: - 'export function main(a: string): string {\n return `hello ${a}`\n}\n', - language: 'bun' - } - } - initContent('bun', script.kind, template) - }} - > - WAC TypeScript - </Button> - </span> - <span title="Workflow-as-Code"> - <Button - size="xs2" - variant="border" - color="light" - startIcon={{ - icon: LanguageIcon, - props: { lang: 'python3', width: 16, height: 16 } - } as ButtonType.Icon} - on:click={() => { - template = 'wac_python' - script.language = 'python3' - script.modules = { - 'helper.py': { - content: 'def main(a: str) -> str:\n return f"hello {a}"\n', - language: 'python3' - } - } - initContent('python3', script.kind, template) - }} - > - WAC Python - </Button> - </span> - <Button - size="xs2" - variant="border" - color="light" - startIcon={{ - icon: LanguageIcon, - props: { lang: 'bun', width: 16, height: 16 } - } as ButtonType.Icon} - on:click={() => { - template = 'ci_test_bun' - script.language = 'bun' - initContent('bun', script.kind, template) - }} - > - CI Test TypeScript - </Button> - <Button - size="xs2" - variant="border" - color="light" - startIcon={{ - icon: LanguageIcon, - props: { lang: 'python3', width: 16, height: 16 } - } as ButtonType.Icon} - on:click={() => { - template = 'ci_test_python' - script.language = 'python3' - initContent('python3', script.kind, template) - }} - > - CI Test Python - </Button> - </div> <!-- Not for dbt: each kind tags a script for a role in a flow that a project bundle cannot fill (approval, trigger, preprocessor), and the runtime only ever runs it as an action. --> @@ -1765,521 +1808,44 @@ </Section> {/if} {#if customUi?.settingsPanel?.disableRuntime} - <Section label="Worker group tag (queue)"> + <Section label="Worker group tag"> <WorkerTagPicker bind:tag={script.tag} placeholder={customUi?.tagSelectPlaceholder} /> </Section> - {/if} - - {#if script.schema && !disableAi && !customUi?.settingsPanel?.metadata?.disableAiFilling} - <div class="mt-3"> - <AIFormSettings - bind:prompt={script.schema.prompt_for_ai as string | undefined} - type="script" - /> - </div> + {@render muteSetting()} {/if} </div> </TabContent> <TabContent value="runtime"> - <div class="flex flex-col gap-8 px-4 py-2 pb-12"> - <Section label="Worker group tag (queue)"> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups" - > - The script will be executed on a worker configured to listen to this worker - group tag (queue). For instance, you could setup an "highmem", or "gpu" tag. - </Tooltip> - {/snippet} - <WorkerTagPicker - bind:tag={script.tag} - placeholder={customUi?.tagSelectPlaceholder} - /> - </Section> - - <Section label="Concurrency limits" eeOnly> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits" - > - Allowed concurrency within a given timeframe - </Tooltip> - {/snippet} - <Toggle - size="sm" - checked={Boolean(script.concurrent_limit)} - on:change={() => { - if (script.concurrent_limit && script.concurrent_limit != undefined) { - script.concurrent_limit = undefined - script.concurrency_time_window_s = undefined - script.concurrency_key = undefined - } else { - script.concurrent_limit = 1 - } - }} - options={{ - right: 'Concurrency limits' - }} - /> - {#if Boolean(script.concurrent_limit)} - <div class="flex flex-col gap-4 mt-2"> - <Label label="Max number of executions within the time window"> - <div class="flex flex-row gap-2 max-w-sm whitespace-nowrap"> - <input - disabled={!$enterpriseLicense} - bind:value={script.concurrent_limit} - type="number" - /> - </div> - </Label> - {#if Boolean(script.concurrent_limit)} - <Label label="Time window in seconds"> - <SecondsInput - disabled={!$enterpriseLicense} - bind:seconds={script.concurrency_time_window_s} - /> - </Label> - <Label label="Custom concurrency key (optional)"> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits#custom-concurrency-key" - > - Concurrency keys are global, you can have them be workspace specific - using the variable `$workspace`. You can also use an argument's - value using `$args[name_of_arg]`</Tooltip - > - {/snippet} - <input - disabled={!$enterpriseLicense} - type="text" - autofocus - bind:value={script.concurrency_key} - placeholder={`$workspace/script/${script.path}-$args[foo]`} - /> - </Label> - {/if} - </div> - {/if} - </Section> - <Section label="Cache"> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/caching" - > - Cache the results for each possible inputs - </Tooltip> - {/snippet} - <div class="flex gap-2 shrink flex-col"> - <Toggle - size="sm" - bind:checked={ - () => !!script.cache_ttl, (v) => (script.cache_ttl = v ? 300 : undefined) - } - options={{ right: 'Cache the results for each possible inputs' }} - /> - {#if script.cache_ttl} - <div class="text-2xs text-secondary">How long to keep the cache valid</div> - <SecondsInput bind:seconds={script.cache_ttl} /> - <Toggle - size="2xs" - bind:checked={ - () => script.cache_ignore_s3_path, - (v) => (script.cache_ignore_s3_path = v || undefined) - } - options={{ - right: 'Ignore S3 Object paths for caching purposes', - rightTooltip: - 'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.' - }} - /> - {/if} - </div> - </Section> - <Section label="Timeout"> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/script_editor/settings#timeout" - > - Add a custom timeout for this script - </Tooltip> - {/snippet} - <div class="flex gap-2 shrink flex-col"> - <Toggle - size="sm" - checked={Boolean(script.timeout)} - on:change={() => { - if (script.timeout && script.timeout != undefined) { - script.timeout = undefined - } else { - script.timeout = customUi?.defaultTimeout ?? 300 - } - }} - options={{ - right: 'Add a custom timeout for this script' - }} - /> - {#if Boolean(script.timeout)} - <span class="text-xs font-semibold text-emphasis leading-none mt-2"> - Timeout duration - </span> - {#if script.timeout} - <SecondsInput bind:seconds={script.timeout} /> - {:else} - <SecondsInput disabled /> - {/if} - {/if} - </div> - </Section> - <Section label="Debouncing"> - <DebounceLimit - size="sm" - bind:debounce_delay_s={script.debounce_delay_s} - bind:debounce_key={script.debounce_key} - bind:debounce_args_to_accumulate={script.debounce_args_to_accumulate} - bind:max_total_debouncing_time={script.max_total_debouncing_time} - bind:max_total_debounces_amount={script.max_total_debounces_amount} - schema={script.schema as Schema} - placeholder={`$workspace/script/${script.path}-$args[foo]`} - /> - - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/job_debouncing" - > - Debounce Jobs - </Tooltip> - {/snippet} - </Section> - - <Section label="Perpetual script"> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/script_editor/perpetual_scripts" - > - Restart the script upon ending unless cancelled - </Tooltip> - {/snippet} - <div class="flex gap-2 shrink flex-col"> - <Toggle - size="sm" - checked={Boolean(script.restart_unless_cancelled)} - on:change={() => { - if (script.restart_unless_cancelled) { - script.restart_unless_cancelled = undefined - } else { - script.restart_unless_cancelled = true - } - }} - options={{ - right: 'Restart upon ending unless cancelled' - }} - /> - </div> - </Section> - <Section label="Dedicated workers" eeOnly> - <Toggle - disabled={!$enterpriseLicense || - isCloudHosted() || - (script.language != 'bun' && - script.language != 'bunnative' && - script.language != 'python3' && - script.language != 'deno')} - size="sm" - checked={Boolean(script.dedicated_worker)} - on:change={() => { - if (script.dedicated_worker) { - script.dedicated_worker = undefined - } else { - script.dedicated_worker = true - } - }} - options={{ - right: 'Script is run on dedicated workers' - }} - /> - {#if script.dedicated_worker} - <div class="py-2"> - <Alert type="info" title="Require dedicated workers"> - A worker group needs to be configured to listen to this script. Select it - in the dedicated workers section of the worker group configuration. - </Alert> - </div> - {/if} - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/dedicated_workers" - > - In this mode, the script is meant to be run on dedicated workers that run - the script at native speed. Can reach >1500rps per dedicated worker. Only - available on enterprise edition and for Python3, Deno, Bun and Bunnative. - For other languages, the efficiency is already on par with dedicated workers - since they do not spawn a full runtime</Tooltip - > - {/snippet} - </Section> - <Section label="Delete after completion"> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/script_editor/settings#delete-after-use" - > - WARNING: This settings ONLY applies to synchronous webhooks or when the - script is used within a flow. If used individually, this script must be - triggered using a synchronous endpoint to have the desired effect. - <br /> - <br /> - The logs, arguments and results of the job will be completely deleted from Windmill - after the specified delay once it is complete and the result has been returned. - Set to 0 for immediate deletion. - <br /> - <br /> - The deletion is irreversible. - {#if !$enterpriseLicense} - <br /> - <br /> - This option is only available on Windmill Enterprise Edition. - {/if} - </Tooltip> - {/snippet} - <div class="flex gap-2 shrink flex-col"> - <Toggle - disabled={!$enterpriseLicense} - size="sm" - checked={script.delete_after_secs != null} - on:change={() => { - if (script.delete_after_secs != null) { - script.delete_after_secs = undefined - } else { - script.delete_after_secs = 0 - } - }} - options={{ - right: 'Delete logs, arguments and results after completion' - }} - /> - {#if script.delete_after_secs != null} - <SecondsInput - bind:seconds={script.delete_after_secs} - disabled={!$enterpriseLicense} - /> - {/if} - </div> - </Section> - {#if !isCloudHosted()} - <Section label="High priority script" eeOnly> - <Toggle - disabled={!$enterpriseLicense || isCloudHosted()} - size="sm" - checked={script.priority !== undefined && script.priority > 0} - on:change={() => { - if (script.priority) { - script.priority = undefined - } else { - script.priority = 100 - } - }} - options={{ - right: 'Label as high priority' - }} - > - {#snippet right()} - <input - type="number" - class="!w-16 ml-4" - disabled={script.priority === undefined} - bind:value={script.priority} - onfocus={bubble('focus')} - onchange={() => { - if (script.priority && script.priority > 100) { - script.priority = 100 - } else if (script.priority && script.priority < 0) { - script.priority = 0 - } - }} - /> - {/snippet} - </Toggle> - {#snippet header()} - <!-- TODO: Add EE-only badge when we have it --> - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/jobs#high-priority-jobs" - > - Jobs from script labeled as high priority take precedence over the other - jobs when in the jobs queue. - {#if !$enterpriseLicense}This is a feature only available on enterprise - edition.{/if} - </Tooltip> - {/snippet} - </Section> - {/if} - <Section label="Runs visibility"> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#invisible-runs" - > - When this option is enabled, manual executions of this script are invisible - to users other than the user running it, including the owner(s). This - setting can be overridden when this script is run manually from the advanced - menu. - </Tooltip> - {/snippet} - <div class="flex gap-2 shrink flex-col"> - <Toggle - size="sm" - checked={Boolean(script.visible_to_runner_only)} - on:change={() => { - if (script.visible_to_runner_only) { - script.visible_to_runner_only = undefined - } else { - script.visible_to_runner_only = true - } - }} - options={{ - right: 'Make runs invisible to others' - }} - /> - </div> - </Section> - <Section label="Job token"> - <JobTokenScopesSetting bind:value={script.job_token_scopes} kind="script" /> - </Section> - <Section - label={canPreserve - ? 'Run on behalf of a specified user' - : 'On behalf of last editor'} - > - {#snippet header()} - <Tooltip> - When this option is enabled, the script will be run with the permissions of - {canPreserve ? 'the specified user' : 'the last editor'}. - </Tooltip> - {/snippet} - <div class="flex gap-2 shrink flex-col"> - <span class="inline-flex gap-2"> - <Toggle - size="sm" - checked={Boolean(script.on_behalf_of_email)} - on:change={() => { - if (script.on_behalf_of_email) { - script.on_behalf_of_email = undefined - script.on_behalf_of = undefined - preserveOnBehalfOf = false - onBehalfOfChoice = undefined - } else { - script.on_behalf_of_email = $userStore?.email - script.on_behalf_of = myPermissionedAs - } - }} - options={{ - right: `Run on behalf of ${canPreserve ? 'a specified user' : 'last editor'}` - }} - /> - {#if script.on_behalf_of_email && canPreserve} - → <OnBehalfOfSelector - targetWorkspace={opWorkspace ?? ''} - targetValue={originalOnBehalfOfEmail} - selected={onBehalfOfChoice} - onSelect={(choice, details) => { - onBehalfOfChoice = choice - if (choice === 'me') { - script.on_behalf_of_email = $userStore?.email - script.on_behalf_of = myPermissionedAs - customOnBehalfOfEmail = '' - preserveOnBehalfOf = false - } else if (choice === 'target') { - // Keep the saved pair. A script that has no recorded principal yet - // sends the email alone and the backend derives one from it. - script.on_behalf_of_email = originalOnBehalfOfEmail - script.on_behalf_of = originalOnBehalfOfPermissionedAs - customOnBehalfOfEmail = '' - preserveOnBehalfOf = true - } else if (choice === 'custom' && details) { - script.on_behalf_of_email = details.email - script.on_behalf_of = details.permissionedAs - customOnBehalfOfEmail = details.email - preserveOnBehalfOf = true - } - }} - kind="script" - {canPreserve} - customValue={customOnBehalfOfEmail} - isDeployment={false} - /> - {:else if script.on_behalf_of_email && !canPreserve} - <span class="text-xs text-tertiary"> - Currently: <span class="font-medium" - >{originalOnBehalfOfEmail ?? script.on_behalf_of_email}</span - >. Will be set to <span class="font-medium">{$userStore?.email}</span> on - deploy (requires admin or wm_deployers group to override) - </span> - {/if} - </span> - </div> - </Section> - {#if !isCloudHosted()} - <Section label="Custom env variables"> - {#snippet header()} - <Tooltip - documentationLink="https://www.windmill.dev/docs/script_editor/custom_environment_variables" - > - Additional static custom env variables to pass to the script. - </Tooltip> - {/snippet} - {#if script.envs && script.envs.length > 0} - <Alert type="warning" title="Not passed in previews" size="xs"> - Static envs variables are not passed in preview but solely on deployed - scripts. - </Alert> - {/if} - <div class="w-full mt-2"> - <span class="text-primary text-xs pb-2">Format is: `{'<KEY>=<VALUE>'}`</span - > - {#if Array.isArray(script.envs ?? [])} - {#each script.envs ?? [] as _v, i} - <div class="flex max-w-md mt-1 w-full items-center relative"> - {#if script.envs} - <input - type="text" - bind:value={script.envs[i]} - placeholder="<KEY>=<VALUE>" - /> - {/if} - <button - transition:fade|local={{ duration: 50 }} - class="rounded-full p-1 bg-surface/60 duration-200 hover:bg-gray-200 absolute right-2" - aria-label="Clear" - onclick={() => { - script.envs && script.envs.splice(i, 1) - script.envs = script.envs - }} - > - <X size={14} /> - </button> - </div> - {/each} - {/if} - </div> - <div class="flex mt-2"> - <Button - variant="default" - size="xs" - on:click={() => { - if (script.envs == undefined || !Array.isArray(script.envs)) { - script.envs = [] - } - script.envs = script.envs.concat('') - }} - > - <div class="flex flex-row gap-1"> - <Plus size="16" /> - Add item - </div> - </Button> - </div> - </Section> - {/if} + <div class="flex flex-col gap-8 p-4 pb-8" {@attach keepDeployedOnBehalfOf}> + <ScriptRuntimeSettings + bind:script + tagPlaceholder={customUi?.tagSelectPlaceholder} + defaultTimeout={customUi?.defaultTimeout} + /> + {@render muteSetting()} + </div> + </TabContent> + <TabContent value="permissions"> + <div class="flex flex-col gap-6 p-4 pb-8"> + <RunOnBehalfOfSetting + kind="script" + bind:email={script.on_behalf_of_email} + bind:permissionedAs={script.on_behalf_of} + bind:preserve={preserveOnBehalfOf} + bind:choice={onBehalfOfChoice} + savedEmail={savedScript?.on_behalf_of_email} + savedPermissionedAs={savedScript?.on_behalf_of} + targetWorkspace={opWorkspace ?? ''} + isNew={isNewScript} + /> + <InvisibleRunsSetting + kind="script" + bind:visibleToRunnerOnly={script.visible_to_runner_only} + /> + <JobTokenScopesSetting bind:value={script.job_token_scopes} kind="script" /> </div> </TabContent> {#if !isDbt} diff --git a/frontend/src/lib/components/copilot/AIFormSettings.svelte b/frontend/src/lib/components/copilot/AIFormSettings.svelte index 4bae2a825a..20458589b7 100644 --- a/frontend/src/lib/components/copilot/AIFormSettings.svelte +++ b/frontend/src/lib/components/copilot/AIFormSettings.svelte @@ -1,6 +1,6 @@ <script lang="ts"> - import Label from '../Label.svelte' import TextInput from '../text_input/TextInput.svelte' + import SettingToggle from '../itemSettings/SettingToggle.svelte' import { copilotInfo } from '$lib/aiStore' interface Props { @@ -9,12 +9,22 @@ } let { prompt = $bindable(), type = 'script' }: Props = $props() + + // An empty prompt is stored as undefined, so the row keeps its own open state to show the field + // between turning the toggle on and typing. + let open = $state(false) </script> {#if !$copilotInfo.workspaceDisabled} - <Label + <SettingToggle + setting="ai-prompt" label="AI prompt" tooltip="An AI choosing this {type}'s inputs already reads its description and each field's description. Anything written here is given to it as extra guidance: mention specific fields and how they interact." + checked={open || !!prompt} + onchange={(on) => { + open = on + if (!on) prompt = undefined + }} > <TextInput underlyingInputEl="textarea" @@ -24,5 +34,5 @@ rows: 3 }} /> - </Label> + </SettingToggle> {/if} diff --git a/frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte b/frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte deleted file mode 100644 index 2aec483e32..0000000000 --- a/frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte +++ /dev/null @@ -1,80 +0,0 @@ -<script lang="ts"> - import Toggle from '$lib/components/Toggle.svelte' - - import { FlowService, ScriptService } from '$lib/gen' - import { sendUserToast } from '$lib/toast' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' - - const operatingWorkspace = useOperatingWorkspace() - - interface Props { - kind: 'script' | 'flow' - scriptOrFlowPath: string - errorHandlerMuted: boolean | undefined - textDisabled?: boolean - color?: 'nord' | 'red' | 'blue' | undefined - } - - let { - kind, - scriptOrFlowPath, - errorHandlerMuted = $bindable(), - textDisabled = false, - color = undefined - }: Props = $props() - - let toggleState = $state(errorHandlerMuted) - - async function toggleErrorHandler(): Promise<void> { - toggleState = !toggleState - if ($operatingWorkspace !== undefined) { - try { - if (kind === 'flow') { - await FlowService.toggleWorkspaceErrorHandlerForFlow({ - workspace: $operatingWorkspace, - path: scriptOrFlowPath, - requestBody: { - muted: !errorHandlerMuted - } - }) - } else { - await ScriptService.toggleWorkspaceErrorHandlerForScript({ - workspace: $operatingWorkspace, - path: scriptOrFlowPath, - requestBody: { - muted: !errorHandlerMuted - } - }) - } - } catch (error) { - sendUserToast( - `Error while toggling Workspace Error Handler: ${error.body || error.message}`, - true - ) - toggleState = false - return - } - errorHandlerMuted = !errorHandlerMuted - toggleState = errorHandlerMuted - sendUserToast( - errorHandlerMuted ? 'Workspace error handler muted' : 'Workspace error handler active', - false - ) - } - } -</script> - -<Toggle - textClass="font-medium" - {color} - size="xs" - checked={toggleState} - on:change={toggleErrorHandler} - options={{ - right: 'Mute', - rightTooltip: 'Disable workspace error handler, EE only', - rightDocumentationLink: - 'https://www.windmill.dev/docs/core_concepts/error_handling#workspace-error-handler' - }} - {textDisabled} -/> diff --git a/frontend/src/lib/components/flows/DebounceLimit.svelte b/frontend/src/lib/components/flows/DebounceLimit.svelte index 3ab55caeed..548e5a91fe 100644 --- a/frontend/src/lib/components/flows/DebounceLimit.svelte +++ b/frontend/src/lib/components/flows/DebounceLimit.svelte @@ -1,11 +1,12 @@ <script lang="ts"> - import Toggle from '$lib/components/Toggle.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import { SecondsInput } from '$lib/components/common' import { enterpriseLicense } from '$lib/stores' import Label from '../Label.svelte' import type { Schema } from '$lib/common' - import { slideDynamic } from '$lib/transitions' + import Select from '$lib/components/select/Select.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import SettingToggle from '$lib/components/itemSettings/SettingToggle.svelte' let { debounce_delay_s = $bindable(), @@ -14,11 +15,7 @@ max_total_debouncing_time = $bindable(), max_total_debounces_amount = $bindable(), schema = undefined, - placeholder, - size = 'xs', - color = undefined, - fontClass = 'font-normal', - indentContent = false + placeholder }: { debounce_delay_s: number | undefined debounce_key: string | undefined @@ -27,11 +24,6 @@ max_total_debounces_amount: number | undefined schema?: Schema placeholder: string - size: 'xs' | 'sm' - color?: 'nord' | undefined - fontClass?: string - /** Align the fields under the toggle's label, as the flow step settings do. */ - indentContent?: boolean } = $props() // Check if an originalType like "string | string[]" is a top-level @@ -90,89 +82,83 @@ } }) - // Presence, not truthiness: a single backspace takes the seeded 1s to 0 while the - // user is still typing, and reading that as off would disable the field mid-edit. - let off = $derived(!$enterpriseLicense || debounce_delay_s === undefined) + let disabled = $derived(!$enterpriseLicense) </script> -<div> - <div class="flex flex-row items-center gap-2"> - <Toggle - textClass={fontClass} - {color} - {size} - disabled={!$enterpriseLicense} - checked={debounce_delay_s !== undefined} - on:change={() => { - if (debounce_delay_s !== undefined) { - debounce_delay_s = undefined - debounce_key = undefined - } else { - debounce_delay_s = 1 - } - }} - options={{ - right: 'Debouncing', - rightTooltip: 'Consolidate multiple executions into a single run within a time window', - rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/job_debouncing' - }} - class="py-1" - eeOnly={true} +<!-- Presence, not truthiness: a single backspace takes the seeded 1s to 0 while the user is + still typing, and reading that as off would close the fields mid-edit. --> +<SettingToggle + setting="debounce" + label="Debouncing" + tooltip="Consolidate multiple executions into a single run within a time window" + documentationLink="https://www.windmill.dev/docs/core_concepts/job_debouncing" + eeOnly + {disabled} + checked={debounce_delay_s !== undefined} + onchange={(on) => { + if (on) { + debounce_delay_s = 1 + } else { + debounce_delay_s = undefined + debounce_key = undefined + } + }} +> + <Label label="Delay in seconds"> + <SecondsInput {disabled} bind:seconds={debounce_delay_s} /> + </Label> + <Label label="Custom debounce key (optional)"> + {#snippet header()} + <Tooltip> + Debounce keys are global. Use `$workspace` to make one workspace specific, and + `$args[name_of_arg]` to use an argument's value. + </Tooltip> + {/snippet} + <TextInput bind:value={debounce_key} inputProps={{ type: 'text', disabled, placeholder }} /> + </Label> + <Label label="Argument to accumulate (optional)"> + {#snippet header()} + <Tooltip> + A list argument whose values are appended together across the debounced executions. + </Tooltip> + {/snippet} + <!-- A saved argument the schema no longer has stays listed, so it can still be cleared. --> + <Select + items={(selectedArg && !arrayArgs.includes(selectedArg) + ? [...arrayArgs, selectedArg] + : arrayArgs + ).map((arg) => ({ value: arg, label: arg }))} + bind:value={() => selectedArg || undefined, (v) => (selectedArg = v ?? '')} + placeholder="None" + clearable + disabled={disabled || (arrayArgs.length === 0 && !selectedArg)} /> - </div> - - {#if !off} - <div class="flex flex-col gap-4 mt-2 {indentContent ? 'pl-9' : ''}" transition:slideDynamic> - <Label label="Delay in seconds"> - <SecondsInput disabled={off} bind:seconds={debounce_delay_s} /> - </Label> - <Label label="Custom debounce key (optional)"> - {#snippet header()} - <Tooltip> - Debounce keys are global, you can have them be workspace specific using the variable - `$workspace`. You can also use an argument's value using `$args[name_of_arg]`</Tooltip - > - {/snippet} - <input type="text" disabled={off} bind:value={debounce_key} {placeholder} /> - </Label> - <Label label="Argument to accumulate (optional)"> - {#snippet header()} - <Tooltip> - Select a list-type argument to accumulate across debounced executions. Values from each - debounced execution will be appended together.</Tooltip - > - {/snippet} - <select disabled={off} bind:value={selectedArg}> - <option value="">None</option> - {#each arrayArgs as arg} - <option value={arg}>{arg}</option> - {/each} - </select> - {#if arrayArgs.length === 0} - <div class="text-xs text-gray-500 mt-1"> - No list-type arguments found in schema. Define arguments with type "array" to enable - accumulation. - </div> - {/if} - </Label> - <Label label="Max total debouncing time (optional)"> - {#snippet header()} - <Tooltip> - Maximum total time (in seconds) that a job can be debounced before it must execute. Once - this time is reached, the job will run regardless of ongoing debouncing.</Tooltip - > - {/snippet} - <SecondsInput disabled={off} bind:seconds={max_total_debouncing_time} /> - </Label> - <Label label="Max total debounces amount (optional)"> - {#snippet header()} - <Tooltip> - Maximum number of times a job can be debounced before it must execute. Once this count - is reached, the job will run regardless of ongoing debouncing.</Tooltip - > - {/snippet} - <input type="number" disabled={off} bind:value={max_total_debounces_amount} min="0" /> - </Label> - </div> - {/if} -</div> + {#if arrayArgs.length === 0} + <div class="text-2xs text-secondary"> + No list argument in the schema. Add an argument of type "array" to accumulate it. + </div> + {/if} + </Label> + <Label label="Max total debouncing time (optional)"> + {#snippet header()} + <Tooltip> + The longest a run can be held back by debouncing. Past it, the run starts whatever + debouncing is ongoing. + </Tooltip> + {/snippet} + <SecondsInput {disabled} bind:seconds={max_total_debouncing_time} /> + </Label> + <Label label="Max total debounces amount (optional)"> + {#snippet header()} + <Tooltip> + The most times a run can be debounced. Past it, the run starts whatever debouncing is + ongoing. + </Tooltip> + {/snippet} + <TextInput + class="w-24" + bind:value={max_total_debounces_amount} + inputProps={{ type: 'number', min: 0, disabled }} + /> + </Label> +</SettingToggle> diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index 404999dfbc..f99fb91dee 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -119,7 +119,7 @@ resolvedCount={resolvedModuleIds.length} /> {:else if selectedId?.startsWith('settings')} - <FlowSettings {enableAi} {noEditor} /> + <FlowSettings {enableAi} {noEditor} {selectedId} {newFlow} /> {:else if selectedId === 'Input'} <FlowInput {noEditor} diff --git a/frontend/src/lib/components/flows/content/FlowModuleDebounce.svelte b/frontend/src/lib/components/flows/content/FlowModuleDebounce.svelte index 17e2cf2d00..fe6ad667af 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleDebounce.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleDebounce.svelte @@ -55,7 +55,4 @@ bind:max_total_debounces_amount schema={flowStateStore.val[selectedId]?.schema} placeholder={`$workspace/flow/<flow_path>-${flowModule.id}`} - size="xs" - fontClass="text-xs font-normal text-primary" - indentContent /> diff --git a/frontend/src/lib/components/flows/content/FlowRunSettings.svelte b/frontend/src/lib/components/flows/content/FlowRunSettings.svelte index 2d91ab42a7..a39f74c2d7 100644 --- a/frontend/src/lib/components/flows/content/FlowRunSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowRunSettings.svelte @@ -29,6 +29,7 @@ import FlowModuleDebounce from './FlowModuleDebounce.svelte' import FlowModuleMock from './FlowModuleMock.svelte' import JobTokenScopesSetting from '$lib/components/JobTokenScopesSetting.svelte' + import SettingGroup from '$lib/components/itemSettings/SettingGroup.svelte' import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte' import { slideDynamic } from '$lib/transitions' @@ -117,21 +118,13 @@ ) </script> -{#snippet sectionHeader(title: string)} - <div class="text-[11px] font-medium uppercase tracking-[0.04em] text-hint"> - {title} - </div> -{/snippet} - <div bind:this={rootEl} class="flex flex-col gap-8 {embedded ? '' : 'flex-1 min-h-0 overflow-auto p-4 pb-8'}" style={embedded ? undefined : 'scrollbar-gutter: stable'} > {#if !isFailure} - <section class="flex flex-col gap-3"> - {@render sectionHeader('Flow control')} - + <SettingGroup label="Flow control"> <div class="flex flex-col gap-6"> <div data-setting="skip"> <FlowModuleSkip bind:flowModule {parentModule} {previousModule} /> @@ -148,13 +141,11 @@ <div data-setting="sleep"> <FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule {isAgentTool} /> </div> - </div></section + </div></SettingGroup > {/if} - <section class="flex flex-col gap-3"> - {@render sectionHeader('Execution policy')} - + <SettingGroup label="Execution policy"> <div class="flex flex-col gap-6"> {#if !loopSubset} <div data-setting="retries"> @@ -348,48 +339,42 @@ </div> {/if} - <div data-setting="job-token"> - <JobTokenScopesSetting - bind:value={flowModule.job_token_scopes} - kind="step" - size="xs" - textClass="text-xs font-normal text-primary" - /> - </div> - </div></section + <JobTokenScopesSetting bind:value={flowModule.job_token_scopes} kind="step" /> + </div></SettingGroup > {#if s3Language && onApplyS3Snippet && !isFailure} - <section class="flex flex-col gap-3" data-setting="s3"> - {@render sectionHeader('S3 snippets')} - <p class="text-xs text-tertiary"> - Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes. - </p> - <div class="flex flex-row items-center justify-between gap-2"> - <ToggleButtonGroup bind:selected={s3Kind} class="w-auto"> - {#snippet children({ item })} - {#if s3Language === 'deno'} - <ToggleButton value="s3_client" small label="S3 lite client" {item} /> - {:else} - <ToggleButton value="s3_client" small label="Boto3" {item} /> - <ToggleButton value="polars" small label="Polars" {item} /> - <ToggleButton value="duckdb" small label="DuckDB" {item} /> - {/if} - {/snippet} - </ToggleButtonGroup> - <Button - size="xs" - variant="default" - on:click={() => s3Snippet && onApplyS3Snippet?.(s3Snippet)} - > - Apply snippet - </Button> - </div> - {#if s3Snippet} - <div class="overflow-auto max-h-64 border rounded-md"> - <HighlightCode language={s3Language} code={s3Snippet} /> + <div data-setting="s3"> + <SettingGroup label="S3 snippets" class="flex flex-col gap-3"> + <p class="text-xs text-tertiary"> + Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes. + </p> + <div class="flex flex-row items-center justify-between gap-2"> + <ToggleButtonGroup bind:selected={s3Kind} class="w-auto"> + {#snippet children({ item })} + {#if s3Language === 'deno'} + <ToggleButton value="s3_client" small label="S3 lite client" {item} /> + {:else} + <ToggleButton value="s3_client" small label="Boto3" {item} /> + <ToggleButton value="polars" small label="Polars" {item} /> + <ToggleButton value="duckdb" small label="DuckDB" {item} /> + {/if} + {/snippet} + </ToggleButtonGroup> + <Button + unifiedSize="xs" + variant="default" + on:click={() => s3Snippet && onApplyS3Snippet?.(s3Snippet)} + > + Apply snippet + </Button> </div> - {/if} - </section> + {#if s3Snippet} + <div class="overflow-auto max-h-64 border rounded-md"> + <HighlightCode language={s3Language} code={s3Snippet} /> + </div> + {/if} + </SettingGroup> + </div> {/if} </div> diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index dacb9efd4d..12da7f16f9 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -1,36 +1,42 @@ <script lang="ts"> - import { run, createBubbler } from 'svelte/legacy' + import { run } from 'svelte/legacy' - const bubble = createBubbler() import Path from '$lib/components/Path.svelte' import FlowCard from '../common/FlowCard.svelte' - import Toggle from '$lib/components/Toggle.svelte' import JobTokenScopesSetting from '$lib/components/JobTokenScopesSetting.svelte' - import { Alert, Button, SecondsInput } from '$lib/components/common' - import { getContext } from 'svelte' + import { Alert, Tab, TabContent, Tabs } from '$lib/components/common' + import { getContext, tick } from 'svelte' import type { FlowEditorContext } from '../types' - import { enterpriseLicense, userStore } from '$lib/stores' - import { isCloudHosted } from '$lib/cloud' - import Tooltip from '$lib/components/Tooltip.svelte' import SimpleEditor from '$lib/components/SimpleEditor.svelte' import { schemaToObject } from '$lib/schema' import type { Schema } from '$lib/common' - import Section from '$lib/components/Section.svelte' import Label from '$lib/components/Label.svelte' - import ErrorHandlerToggleButtonV2 from '$lib/components/details/ErrorHandlerToggleButtonV2.svelte' - import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte' + import Tooltip from '$lib/components/Tooltip.svelte' + import SettingGroup from '$lib/components/itemSettings/SettingGroup.svelte' import MetadataGen from '$lib/components/copilot/MetadataGen.svelte' - import Badge from '$lib/components/Badge.svelte' import LabelsInput from '$lib/components/LabelsInput.svelte' import AIFormSettings from '$lib/components/copilot/AIFormSettings.svelte' - import { twMerge } from 'tailwind-merge' - import { inputBaseClass, inputBorderClass } from '$lib/components/text_input/TextInput.svelte' - import { slide } from 'svelte/transition' import DebounceLimit from '../DebounceLimit.svelte' - import EEOnly from '$lib/components/EEOnly.svelte' - import OnBehalfOfSelector, { - type OnBehalfOfChoice - } from '$lib/components/OnBehalfOfSelector.svelte' + import SettingToggle from '$lib/components/itemSettings/SettingToggle.svelte' + import WorkerTagSetting from '$lib/components/itemSettings/WorkerTagSetting.svelte' + import ConcurrencyLimitSetting from '$lib/components/itemSettings/ConcurrencyLimitSetting.svelte' + import CacheSetting from '$lib/components/itemSettings/CacheSetting.svelte' + import PrioritySetting from '$lib/components/itemSettings/PrioritySetting.svelte' + import DedicatedWorkersSetting from '$lib/components/itemSettings/DedicatedWorkersSetting.svelte' + import DeleteAfterUseSetting from '$lib/components/itemSettings/DeleteAfterUseSetting.svelte' + import RunOnBehalfOfSetting from '$lib/components/itemSettings/RunOnBehalfOfSetting.svelte' + import MuteErrorHandlerSetting from '$lib/components/itemSettings/MuteErrorHandlerSetting.svelte' + import type { OnBehalfOfChoice } from '$lib/components/OnBehalfOfSelector.svelte' + import InvisibleRunsSetting from '$lib/components/itemSettings/InvisibleRunsSetting.svelte' + import SettingIcons from '$lib/components/itemSettings/SettingIcons.svelte' + import ActiveSettingChips, { + revealSetting + } from '$lib/components/itemSettings/ActiveSettingChips.svelte' + import { + flowRuntimeFields, + getActivePermissionBadges, + getActiveScriptSettingsBadges + } from '$lib/components/scriptSettings' import { modulesWithRetryOrSleep, SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte' import { earlyReturnCandidates } from '../earlyReturnCandidates' import Select from '$lib/components/select/Select.svelte' @@ -38,17 +44,20 @@ useOperatingUser, useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { keepsDeployedOnBehalfOf } from '$lib/components/itemSettings/onBehalfOf' const operatingWorkspace = useOperatingWorkspace() - const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) interface Props { noEditor: boolean enableAi?: boolean + /** The `settings-…` selection id the panel was opened with. */ + selectedId?: string + /** Never deployed, though it may already have a path: a draft-only flow. */ + newFlow?: boolean } - let { noEditor, enableAi }: Props = $props() + let { noEditor, enableAi, selectedId, newFlow = false }: Props = $props() const { flowStore, @@ -62,12 +71,56 @@ opWorkspace } = getContext<FlowEditorContext>('FlowEditorContext') - const WM_DEPLOYERS_GROUP = 'wm_deployers' - let isDeployer = $derived(actingUser?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) - let canPreserve = $derived(!!actingUser?.is_admin || !!actingUser?.is_super_admin || isDeployer) let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined) - let customOnBehalfOfEmail: string = $state('') - let myPermissionedAs = $derived($userStore?.username ? `u/${$userStore.username}` : undefined) + const operatingUser = useOperatingUser() + function keepDeployedOnBehalfOf() { + if ( + !keepsDeployedOnBehalfOf( + operatingUser.current, + onBehalfOfChoice, + flowStore.val.on_behalf_of_email, + $savedOnBehalfOfEmail + ) + ) + return + onBehalfOfChoice = 'target' + flowStore.val.on_behalf_of_email = $savedOnBehalfOfEmail + flowStore.val.on_behalf_of = $savedOnBehalfOfPermissionedAs + $preserveOnBehalfOf = true + } + + type FlowSettingsTab = 'metadata' | 'runtime' | 'permissions' + + // Other panels open the settings on one setting by selecting `settings-<setting>`; the value + // is the row's `data-setting`. + const RUNTIME_ROW_OF_SELECTION: Record<string, string> = { + 'settings-worker-group': 'worker-group', + 'settings-concurrency': 'concurrency', + 'settings-cache': 'cache', + 'settings-early-stop': 'early-stop', + 'settings-early-return': 'early-return', + 'settings-same-worker': 'same-worker' + } + function tabOf(id: string | undefined): FlowSettingsTab { + if (id === 'settings-runtime' || (id && id in RUNTIME_ROW_OF_SELECTION)) return 'runtime' + return 'metadata' + } + // Follows the selection, and a click on a tab overrides it until the next one. + let tab: FlowSettingsTab = $derived(tabOf(selectedId)) + + function scrollToSelectedRow(node: HTMLElement) { + const row = selectedId ? RUNTIME_ROW_OF_SELECTION[selectedId] : undefined + if (row) node.querySelector(`[data-setting="${row}"]`)?.scrollIntoView({ block: 'nearest' }) + } + + let runtimeBadges = $derived(getActiveScriptSettingsBadges(flowRuntimeFields(flowStore.val))) + let permissionBadges = $derived(getActivePermissionBadges(flowStore.val)) + let tabsContent: HTMLElement | undefined = $state(undefined) + async function openSetting(target: string, setting: string) { + tab = target as FlowSettingsTab + await tick() + revealSetting(tabsContent, setting) + } function asSchema(x: any) { return x as Schema @@ -75,670 +128,334 @@ let path: Path | undefined = $state(undefined) let dirtyPath = $state(false) - let displayWorkerTagPicker = $state(false) - // Only block turning it on: a flow deployed with both (CLI, YAML editor) must stay fixable. let conflictingModuleIds = $derived( flowStore.val.value.same_worker ? [] : modulesWithRetryOrSleep(flowStore.val.value) ) run(() => { - if (flowStore.val.tag) { - displayWorkerTagPicker = true - } else if (flowStore.val.value.preserve_step_tags) { - // preserve_step_tags has no effect without a flow worker tag; clear it so it - // doesn't linger as invisible state when the tag is removed via any path - // (the picker, the toggle, or the YAML editor). + // preserve_step_tags has no effect without a flow worker tag; clear it so it doesn't + // linger as invisible state when the tag is removed via any path (the picker, the + // toggle, or the YAML editor). + if (!flowStore.val.tag && flowStore.val.value.preserve_step_tags) { flowStore.val.value.preserve_step_tags = undefined } }) - - let activeAdvancedOptions = $derived([ - { - name: 'AI prompt', - active: !!flowStore.val.schema?.prompt_for_ai - }, - { - name: 'High Priority', - active: flowStore.val.value.priority !== undefined && flowStore.val.value.priority > 0 - }, - { name: 'Error Handler Muted', active: Boolean(flowStore.val.ws_error_handler_muted) }, - { name: 'Invisible to Others', active: Boolean(flowStore.val.visible_to_runner_only) }, - { name: 'Restricted Job Token', active: flowStore.val.job_token_scopes != null }, - { name: 'Shared Directory', active: Boolean(flowStore.val.value.same_worker) }, - { name: 'Preserve Step Tags', active: Boolean(flowStore.val.value.preserve_step_tags) }, - { name: 'Cache Results', active: Boolean(flowStore.val.value.cache_ttl) }, - { name: 'Early Stop', active: Boolean(flowStore.val.value.skip_expr) }, - { name: 'Early Return', active: Boolean(flowStore.val.value.early_return) }, - { name: 'Dedicated Worker', active: Boolean(flowStore.val.dedicated_worker) }, - { name: 'Concurrent Limit', active: Boolean(flowStore.val.value.concurrent_limit) }, - { name: 'Debouncing', active: Boolean(flowStore.val.value.debounce_delay_s) }, - { - name: `Run on Behalf of ${flowStore.val.on_behalf_of_email ?? 'Last Editor'}`, - active: Boolean(flowStore.val.on_behalf_of_email) - }, - { name: 'Worker Tag', active: displayWorkerTagPicker } - ]) - - let activeAdvancedOptionNames = $derived( - activeAdvancedOptions.filter((option) => option.active).map((option) => option.name) - ) - let numberOfAdvancedOptionsOn = $derived(activeAdvancedOptionNames.length) </script> +{#snippet sameWorkerConflict()} + {SAME_WORKER_INCOMPATIBLE_MSG} Remove them from step{conflictingModuleIds.length > 1 ? 's' : ''} + {conflictingModuleIds.join(', ')} first. +{/snippet} + <div class="h-full flex flex-col"> - <FlowCard {noEditor} title="Settings"> - <div class="grow min-h-0 p-4 h-full flex flex-col gap-6 overflow-y-auto"> - <!-- Metadata Section --> - <div class="gap-6 flex flex-col"> - <Label label="Summary"> - <MetadataGen - bind:content={flowStore.val.summary} - promptConfigName="flowSummary" - flow={flowStore.val.value} - onChange={() => { - if ($initialPathStore == '' && flowStore.val.summary?.length > 0 && !dirtyPath) { - path?.setName( - flowStore.val.summary - .toLowerCase() - .replace(/[^a-z0-9_]/g, '_') - .replace(/-+/g, '_') - .replace(/^-|-$/g, '') - ) - } - }} - elementProps={{ - type: 'text', - id: 'flow-summary', - placeholder: 'Short summary to be displayed when listed' - }} - /> - </Label> - <!-- prettier-ignore --> - <LabelsInput bind:labels={(flowStore.val as any).labels} class="-mt-4" /> - - {#if !noEditor && customUi?.topBar?.editablePath != false} - <Label label="Path"> - <Path - autofocus={false} - bind:this={path} - bind:dirty={dirtyPath} - bind:path={$pathStore} - initialPath={$initialPathStore} - namePlaceholder="flow" - kind="flow" - workspaceOverride={opWorkspace?.()} - /> - {#if $initialPathStore && $pathStore && $pathStore !== $initialPathStore} - <Alert - type="info" - size="xs" - title="Deploy the flow to make the path change effective." - class="mt-2" - /> - {/if} - </Label> - {/if} - - <Label label="Description"> - <MetadataGen - bind:content={flowStore.val.description} - promptConfigName="flowDescription" - flow={flowStore.val.value} - class="w-full" - elementType="textarea" - elementProps={{ - id: 'inp', - placeholder: 'What this flow does and how to use it' - }} - /> - </Label> + <FlowCard {noEditor}> + {#snippet header()} + <!-- The title is drawn here rather than through `title` so the chips sit beside it. --> + <div class="mr-auto flex min-w-0 items-center gap-3 pr-2"> + <div class="shrink-0 text-sm font-semibold text-emphasis">Settings</div> + <ActiveSettingChips + groups={[ + { tab: 'runtime', badges: runtimeBadges }, + { tab: 'permissions', badges: permissionBadges } + ]} + onopen={openSetting} + /> </div> + {/snippet} + <div class="flex flex-col h-full"> + <Tabs bind:selected={tab} wrapperClass="flex-none w-full"> + <Tab value="metadata" label="Metadata" /> + <Tab value="runtime" label="Runtime"> + {#snippet extra()} + <SettingIcons badges={runtimeBadges} /> + {/snippet} + </Tab> + <Tab value="permissions" label="Permissions"> + {#snippet extra()} + <SettingIcons badges={permissionBadges} /> + {/snippet} + </Tab> - <!-- Deployable Section --> - <Section - label="Advanced" - collapsable={true} - small={true} - class="h-full grow mt-2 min-h-0 flex flex-col gap-6 pb-2" - > - <!-- Worker Group Section --> - {#if customUi?.settingsTabs?.workerGroup != false} - <div> - <Toggle - textClass="font-medium" - size="xs" - checked={displayWorkerTagPicker} - on:change={() => { - displayWorkerTagPicker = !displayWorkerTagPicker - if (!displayWorkerTagPicker) { - // Clearing the tag triggers the reactive block above, which also - // resets preserve_step_tags so it doesn't linger as invisible state. - flowStore.val.tag = undefined - } - }} - options={{ - right: 'Worker group tag (queue)', - rightTooltip: - "When a worker group tag is defined at the flow level, any steps inside the flow will run on any worker group that listen to that tag, regardless of the steps tag. If no worker group tags is defined, the flow controls will be executed with the default tag 'flow' and the steps will be executed with their respective tag", - rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/worker_groups' - }} - /> - - {#if displayWorkerTagPicker} - <div transition:slide={{ duration: 120 }} class="mt-2"> - <WorkerTagPicker - bind:tag={flowStore.val.tag} - popupPlacement="top-end" - workspaceId={opWorkspace?.()} - /> - </div> - {#if flowStore.val.tag} - <div - transition:slide={{ duration: 120 }} - class="mt-2 pl-4 border-l border-surface-selected" - > - <Toggle - textClass="font-medium" - size="xs" - bind:checked={flowStore.val.value.preserve_step_tags} - options={{ - right: 'Preserve step worker tags', - rightTooltip: - 'By default the flow worker tag above is propagated to and overrides every step, script and nested sub-flow. Enable this to instead let steps that declare their own worker tag run on it. Steps without their own tag still inherit the flow tag.', - rightDocumentationLink: - 'https://www.windmill.dev/docs/core_concepts/worker_groups' + {#snippet content()} + <div class="min-h-0 grow overflow-y-auto" bind:this={tabsContent}> + <TabContent value="metadata"> + <div class="flex flex-col gap-6 p-4 pb-8"> + <Label label="Summary"> + <MetadataGen + bind:content={flowStore.val.summary} + promptConfigName="flowSummary" + flow={flowStore.val.value} + onChange={() => { + if ( + $initialPathStore == '' && + flowStore.val.summary?.length > 0 && + !dirtyPath + ) { + path?.setName( + flowStore.val.summary + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/-+/g, '_') + .replace(/^-|-$/g, '') + ) + } + }} + elementProps={{ + type: 'text', + id: 'flow-summary', + placeholder: 'Short summary to be displayed when listed' }} /> - </div> - {/if} - {/if} - </div> + </Label> + <!-- prettier-ignore --> + <LabelsInput bind:labels={(flowStore.val as any).labels} class="-mt-4" /> - <!-- <Tooltip - >In this mode, every scripts of this flow is run on the workers dedicated to this flow - that keep the scripts "hot" so that there is not cold start cost incurred. Steps can run - at >1500 rps in this mode.</Tooltip - > --> - {/if} + {#if !noEditor && customUi?.topBar?.editablePath != false} + <Label label="Path"> + {#snippet header()} + <Tooltip + documentationLink="https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path" + > + The unique identifier of the flow in the workspace that defines permissions + </Tooltip> + {/snippet} + <Path + autofocus={false} + bind:this={path} + bind:dirty={dirtyPath} + bind:path={$pathStore} + initialPath={$initialPathStore} + namePlaceholder="flow" + kind="flow" + workspaceOverride={opWorkspace?.()} + /> + {#if $initialPathStore && $pathStore && $pathStore !== $initialPathStore} + <Alert + type="info" + size="xs" + title="Deploy the flow to make the path change effective." + class="mt-2" + /> + {/if} + </Label> + {/if} - <!-- Metadata Advanced Section --> - {#snippet badge()} - {#if numberOfAdvancedOptionsOn > 0} - <div class="flex grow min-w-0 w-full flex-wrap gap-1 ps-2"> - {#each activeAdvancedOptionNames as optionName} - <Badge twBgColor="bg-surface-sunken" twTextColor="text-primary">{optionName}</Badge> - {/each} - </div> - {/if} - {/snippet} - - <!-- Cache Section --> - {#if customUi?.settingsTabs?.cache != false} - <div> - <Toggle - textClass="font-medium" - size="xs" - checked={Boolean(flowStore.val.value.cache_ttl)} - on:change={() => { - if (flowStore.val.value.cache_ttl && flowStore.val.value.cache_ttl != undefined) { - flowStore.val.value.cache_ttl = undefined - } else { - flowStore.val.value.cache_ttl = 300 - } - }} - options={{ - right: 'Cache the results for each possible inputs', - rightTooltip: - 'When enabled, the flow will cache the results of the flow for each possible set of inputs.', - rightDocumentationLink: 'https://www.windmill.dev/docs/flows/cache#cache-flows' - }} - /> - {#if flowStore.val.value.cache_ttl} - <div class="flex gap-x-4 flex-col gap-1 mt-2" transition:slide={{ duration: 120 }}> - <div class="text-2xs text-secondary">How long to keep the cache valid</div> - <SecondsInput bind:seconds={flowStore.val.value.cache_ttl} /> - <Toggle - size="2xs" - bind:checked={ - () => flowStore.val.value.cache_ignore_s3_path, - (v) => (flowStore.val.value.cache_ignore_s3_path = v || undefined) - } - options={{ - right: 'Ignore S3 Object paths for caching purposes', - rightTooltip: - 'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.' - }} - /> - </div> - {/if} - </div> - {/if} - - <!-- Early Stop Section --> - {#if customUi?.settingsTabs?.earlyStop != false} - <div> - <!-- documentationLink="https://www.windmill.dev/docs/flows/early_stop --> - <Toggle - textClass="font-medium" - size="xs" - checked={Boolean(flowStore.val.value.skip_expr)} - on:change={() => { - if (Boolean(flowStore.val.value.skip_expr) && flowStore.val.value.skip_expr) { - flowStore.val.value.skip_expr = undefined - } else { - flowStore.val.value.skip_expr = 'flow_input.foo == undefined' - } - }} - options={{ - right: 'Early stop if condition met', - rightTooltip: - 'If the inputs meet the predefined condition, the flow will not run.' + - 'to decide if the flow should stop early.', - rightDocumentationLink: - 'https://www.windmill.dev/docs/flows/early_stop#early-stop-for-flow' - }} - /> - {#if flowStore.val.value.skip_expr} - <div - class="w-full border rounded-md flex flex-col mt-2 {flowStore.val.value.skip_expr - ? '' - : 'bg-surface-secondary'}" - > - <div class="w-full rounded-md overflow-auto"> - <SimpleEditor - lang="javascript" - small - bind:code={flowStore.val.value.skip_expr} - class="small-editor" - extraLib={`declare const flow_input = ${JSON.stringify( - schemaToObject(asSchema(flowStore.val.schema), previewArgs.val) - )}; - declare const WM_SCHEDULED_FOR: string;`} + <Label label="Description"> + <MetadataGen + bind:content={flowStore.val.description} + promptConfigName="flowDescription" + flow={flowStore.val.value} + class="w-full" + elementType="textarea" + elementProps={{ + id: 'inp', + placeholder: 'What this flow does and how to use it' + }} /> - <div class="text-xs text-hint p-2"> - You can use the variable `flow_input` to access the inputs of the flow. <br - />The variable `WM_SCHEDULED_FOR` contains the time the flow was scheduled for - which you can use to stop early non fresh jobs: - <pre>new Date().getTime() - new Date(WM_SCHEDULED_FOR).getTime() {'>'} X</pre> - </div> - </div> - </div> - {/if} - </div> - {/if} + </Label> - <!-- Early Return Section --> - {#if customUi?.settingsTabs?.earlyReturn != false} - <div> - <Toggle - textClass="font-medium" - size="xs" - checked={Boolean(flowStore.val.value.early_return)} - on:change={() => { - if (Boolean(flowStore.val.value.early_return) && flowStore.val.value.early_return) { - flowStore.val.value.early_return = undefined - } else { - flowStore.val.value.early_return = flowStore.val.value.modules?.[0]?.id ?? 'a' - } - }} - options={{ - right: 'Early return for sync webhooks', - rightTooltip: - 'If defined, sync endpoints will return early at the node defined here while the rest of the flow continue asynchronously.', - rightDocumentationLink: 'https://www.windmill.dev/docs/flows/early_return' - }} - /> - {#if flowStore.val.value.early_return} - {@const candidates = earlyReturnCandidates(flowStore.val.value.modules ?? [])} - {@const selected = candidates.find((c) => c.id === flowStore.val.value.early_return)} - <div class="mt-2 flex flex-col gap-1 max-w-sm" transition:slide={{ duration: 120 }}> - <Select - items={candidates.map((c) => ({ value: c.id, label: c.id }))} - bind:value={flowStore.val.value.early_return} - placeholder="Node's id" - size="sm" - /> - {#if !selected} - <span class="text-2xs text-red-600 dark:text-red-400"> - Step {flowStore.val.value.early_return} is not one of the steps above. Pick another - step. - </span> - {:else if !selected.deterministic} - <span class="text-2xs text-secondary"> - This step only runs if its branch is taken. Otherwise, sync calls return the - flow's final result. - </span> + {#if flowStore.val.schema && enableAi} + <AIFormSettings + bind:prompt={flowStore.val.schema.prompt_for_ai as string | undefined} + type="flow" + /> {/if} </div> - {/if} - </div> - {/if} + </TabContent> - <!-- Shared Directory Section --> - {#if customUi?.settingsTabs?.sharedDiretory != false} - <div class="flex flex-col gap-1"> - <Toggle - textClass="font-medium" - size="xs" - disabled={conflictingModuleIds.length > 0} - bind:checked={flowStore.val.value.same_worker} - options={{ - right: 'Same Worker + Shared directory on `./shared`', - rightTooltip: - 'Steps will share a folder at `./shared` in which they can store heavier data and ' + - 'pass them to the next step. Beware that the `./shared` folder is not ' + - 'preserved across suspends and sleeps.', - rightDocumentationLink: - 'https://www.windmill.dev/docs/core_concepts/persistent_storage/within_windmill#shared-directory' - }} - /> - {#if conflictingModuleIds.length > 0} - <span class="text-xs text-secondary"> - {SAME_WORKER_INCOMPATIBLE_MSG} Remove them from step{conflictingModuleIds.length > 1 - ? 's' - : ''} - {conflictingModuleIds.join(', ')} first. - </span> - {/if} - </div> - {/if} + <!-- The rows, their wording and order are the script Runtime tab's; Flow control is flow-only. --> + <TabContent value="runtime"> + <div + class="flex flex-col gap-8 p-4 pb-8" + {@attach scrollToSelectedRow} + {@attach keepDeployedOnBehalfOf} + > + {#if customUi?.settingsTabs?.earlyStop != false || customUi?.settingsTabs?.earlyReturn != false} + <SettingGroup label="Flow control" class="flex flex-col gap-6"> + {#if customUi?.settingsTabs?.earlyStop != false} + <SettingToggle + setting="early-stop" + label="Stop flow if" + tooltip="Evaluated on the flow inputs before the first step. When true, the flow stops right away and returns 'stopped early'." + documentationLink="https://www.windmill.dev/docs/flows/early_stop#early-stop-for-flow" + checked={!!flowStore.val.value.skip_expr} + onchange={(on) => + (flowStore.val.value.skip_expr = on + ? 'flow_input.foo == undefined' + : undefined)} + > + <div class="w-full border rounded-md overflow-auto"> + <SimpleEditor + lang="javascript" + small + bind:code={ + () => flowStore.val.value.skip_expr, + (code) => { + // The editor writes its text back when it loses focus, which can + // land while the row closes and would turn the setting back on. + if (flowStore.val.value.skip_expr !== undefined) { + flowStore.val.value.skip_expr = code + } + } + } + class="few-lines-editor" + extraLib={`declare const flow_input = ${JSON.stringify( + schemaToObject(asSchema(flowStore.val.schema), previewArgs.val) + )}; + declare const WM_SCHEDULED_FOR: string;`} + /> + </div> + <div class="text-2xs text-secondary"> + <code>flow_input</code> holds the flow inputs and + <code>WM_SCHEDULED_FOR</code> the time the run was scheduled for, e.g. to + stop runs that started too late: + <code + >new Date().getTime() - new Date(WM_SCHEDULED_FOR).getTime() {'>'} X</code + > + </div> + </SettingToggle> + {/if} + {#if customUi?.settingsTabs?.earlyReturn != false} + <SettingToggle + setting="early-return" + label="Early return for sync webhooks" + tooltip="Sync endpoints return the result of the step picked here while the rest of the flow keeps running." + documentationLink="https://www.windmill.dev/docs/flows/early_return" + checked={!!flowStore.val.value.early_return} + onchange={(on) => + (flowStore.val.value.early_return = on + ? (flowStore.val.value.modules?.[0]?.id ?? 'a') + : undefined)} + > + {@const candidates = earlyReturnCandidates( + flowStore.val.value.modules ?? [] + )} + {@const selected = candidates.find( + (c) => c.id === flowStore.val.value.early_return + )} + <div class="max-w-sm"> + <Select + items={candidates.map((c) => ({ value: c.id, label: c.id }))} + bind:value={flowStore.val.value.early_return} + placeholder="Node's id" + /> + </div> + {#if !selected} + <span class="text-2xs text-red-600 dark:text-red-400"> + Step {flowStore.val.value.early_return} is not one of the steps above. Pick + another step. + </span> + {:else if !selected.deterministic} + <span class="text-2xs text-secondary"> + This step only runs if its branch is taken. Otherwise, sync calls return + the flow's final result. + </span> + {/if} + </SettingToggle> + {/if} + </SettingGroup> + {/if} - <!-- Visibility Section --> - <Toggle - textClass="font-medium" - size="xs" - checked={Boolean(flowStore.val.visible_to_runner_only)} - on:change={() => { - if (flowStore.val.visible_to_runner_only) { - flowStore.val.visible_to_runner_only = undefined - } else { - flowStore.val.visible_to_runner_only = true - } - }} - options={{ - right: 'Make runs invisible to others', - rightTooltip: - 'When this option is enabled, manual executions of this script are invisible to users other than the user running it, including the owner(s). This setting can be overridden when this script is run manually from the advanced menu.', - rightDocumentationLink: - 'https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#invisible-runs' - }} - /> - - <!-- On behalf of last editor section --> - <span class="inline-flex gap-2"> - <Toggle - textClass="font-medium" - size="xs" - checked={Boolean(flowStore.val.on_behalf_of_email)} - on:change={() => { - if (flowStore.val.on_behalf_of_email) { - flowStore.val.on_behalf_of_email = undefined - flowStore.val.on_behalf_of = undefined - $preserveOnBehalfOf = false - onBehalfOfChoice = undefined - } else { - flowStore.val.on_behalf_of_email = $userStore?.email - flowStore.val.on_behalf_of = myPermissionedAs - } - }} - options={{ - right: `Run on behalf of ${canPreserve ? 'a specified user' : 'last editor'}`, - rightTooltip: - 'When this option is enabled, the flow will be run with the permissions of the corresponding user.' - }} - /> - {#if flowStore.val.on_behalf_of_email && canPreserve} - → <OnBehalfOfSelector - targetWorkspace={opWorkspace?.() ?? $operatingWorkspace ?? ''} - targetValue={$savedOnBehalfOfEmail} - selected={onBehalfOfChoice} - onSelect={(choice, details) => { - onBehalfOfChoice = choice - if (choice === 'me') { - flowStore.val.on_behalf_of_email = $userStore?.email - flowStore.val.on_behalf_of = myPermissionedAs - customOnBehalfOfEmail = '' - $preserveOnBehalfOf = false - } else if (choice === 'target') { - // Keep the saved pair. A flow that has no recorded principal yet - // sends the email alone and the backend derives one from it. - flowStore.val.on_behalf_of_email = $savedOnBehalfOfEmail - flowStore.val.on_behalf_of = $savedOnBehalfOfPermissionedAs - customOnBehalfOfEmail = '' - $preserveOnBehalfOf = true - } else if (choice === 'custom' && details) { - flowStore.val.on_behalf_of_email = details.email - flowStore.val.on_behalf_of = details.permissionedAs - customOnBehalfOfEmail = details.email - $preserveOnBehalfOf = true - } - }} - kind="flow" - {canPreserve} - customValue={customOnBehalfOfEmail} - isDeployment={false} - /> - {:else if flowStore.val.on_behalf_of_email && !canPreserve} - <span class="text-xs text-tertiary"> - Currently: <span class="font-medium" - >{$savedOnBehalfOfEmail ?? flowStore.val.on_behalf_of_email}</span - >. Will be set to <span class="font-medium">{$userStore?.email}</span> on deploy (requires - admin or wm_deployers group to override) - </span> - {/if} - </span> - - <!-- Error Handler Section --> - <div class="flex flex-row items-center py-1"> - <ErrorHandlerToggleButtonV2 - kind="flow" - scriptOrFlowPath={$pathStore} - bind:errorHandlerMuted={flowStore.val.ws_error_handler_muted} - /> - {#if !$enterpriseLicense} - <EEOnly /> - {/if} - </div> - - <!-- Concurrency Section --> - {#if customUi?.settingsTabs?.concurrency != false} - <div> - <Toggle - textClass="font-medium" - size="xs" - disabled={!$enterpriseLicense} - checked={Boolean(flowStore.val.value.concurrent_limit)} - on:change={() => { - if (flowStore.val.value.concurrent_limit) { - flowStore.val.value.concurrent_limit = undefined - } else { - flowStore.val.value.concurrent_limit = 1 - } - }} - options={{ - right: 'Concurrency limits', - rightTooltip: 'Allowed concurrency within a given timeframe', - rightDocumentationLink: 'https://www.windmill.dev/docs/flows/concurrency_limit' - }} - eeOnly={true} - /> - - {#if flowStore.val.value.concurrent_limit} - <div class="flex flex-col gap-6 mt-6" transition:slide={{ duration: 120 }}> - <Label label="Max number of executions within the time window"> - <div class="flex flex-row gap-2 max-w-sm whitespace-nowrap"> - <input - disabled={!$enterpriseLicense} - bind:value={flowStore.val.value.concurrent_limit} - type="number" + <SettingGroup label="Execution policy" class="flex flex-col gap-6"> + {#if customUi?.settingsTabs?.concurrency != false} + <ConcurrencyLimitSetting + kind="flow" + bind:limit={flowStore.val.value.concurrent_limit} + bind:timeWindowS={flowStore.val.value.concurrency_time_window_s} + bind:concurrencyKey={flowStore.val.value.concurrency_key} + keyPlaceholder={`$workspace/flow/${$pathStore}-$args[foo]`} /> - <Button - size="sm" - color="light" - on:click={() => { - flowStore.val.value.concurrent_limit = undefined - }} - variant="border">Remove Limits</Button - > - </div> - </Label> - <Label label="Time window in seconds"> - <div class="-mt-5"> - <SecondsInput - disabled={!$enterpriseLicense} - bind:seconds={flowStore.val.value.concurrency_time_window_s} + {/if} + {#if customUi?.settingsTabs?.debouncing != false} + <DebounceLimit + bind:debounce_delay_s={flowStore.val.value.debounce_delay_s} + bind:debounce_key={flowStore.val.value.debounce_key} + bind:debounce_args_to_accumulate={ + flowStore.val.value.debounce_args_to_accumulate + } + bind:max_total_debouncing_time={flowStore.val.value.max_total_debouncing_time} + bind:max_total_debounces_amount={ + flowStore.val.value.max_total_debounces_amount + } + schema={asSchema(flowStore.val.schema)} + placeholder={`$workspace/flow/${$pathStore}-$args[foo]`} /> - </div> - </Label> - <Label label="Custom concurrency key (optional)"> - {#snippet header()} - <Tooltip> - Concurrency keys are global, you can have them be workspace specific using the - variable `$workspace`. You can also use an argument's value using - `$args[name_of_arg]`</Tooltip - > - {/snippet} - <!-- svelte-ignore a11y_autofocus --> - <input - type="text" - autofocus - disabled={!$enterpriseLicense} - bind:value={flowStore.val.value.concurrency_key} - placeholder={`$workspace/script/${$pathStore}-$args[foo]`} + {/if} + {#if customUi?.settingsTabs?.cache != false} + <CacheSetting + kind="flow" + bind:ttl={flowStore.val.value.cache_ttl} + bind:ignoreS3Path={flowStore.val.value.cache_ignore_s3_path} + /> + {/if} + <PrioritySetting kind="flow" bind:priority={flowStore.val.value.priority} /> + <DeleteAfterUseSetting + kind="flow" + bind:deleteAfterSecs={flowStore.val.value.delete_after_secs} /> - </Label> + </SettingGroup> + + <SettingGroup label="Workers" class="flex flex-col gap-6"> + {#if customUi?.settingsTabs?.workerGroup != false} + <WorkerTagSetting + kind="flow" + bind:tag={flowStore.val.tag} + bind:preserveStepTags={flowStore.val.value.preserve_step_tags} + workspaceId={opWorkspace?.()} + /> + {/if} + <DedicatedWorkersSetting + kind="flow" + bind:dedicatedWorker={flowStore.val.dedicated_worker} + /> + {#if customUi?.settingsTabs?.sharedDiretory != false} + <SettingToggle + setting="same-worker" + label="Shared directory" + tooltip="Every step runs on the same worker and shares a `./shared` folder to pass heavier data to the next step. The folder is not kept across suspends and sleeps." + documentationLink="https://www.windmill.dev/docs/core_concepts/persistent_storage/within_windmill#shared-directory" + disabled={conflictingModuleIds.length > 0} + checked={!!flowStore.val.value.same_worker} + onchange={(on) => (flowStore.val.value.same_worker = on)} + hint={conflictingModuleIds.length > 0 ? sameWorkerConflict : undefined} + /> + {/if} + </SettingGroup> + + <SettingGroup label="Error handling" class="flex flex-col gap-6"> + <MuteErrorHandlerSetting + kind="flow" + path={$pathStore} + bind:muted={flowStore.val.ws_error_handler_muted} + /> + </SettingGroup> </div> - {/if} + </TabContent> + + <TabContent value="permissions"> + <div class="flex flex-col gap-6 p-4 pb-8"> + <RunOnBehalfOfSetting + kind="flow" + bind:email={flowStore.val.on_behalf_of_email} + bind:permissionedAs={flowStore.val.on_behalf_of} + bind:preserve={$preserveOnBehalfOf} + bind:choice={onBehalfOfChoice} + savedEmail={$savedOnBehalfOfEmail} + savedPermissionedAs={$savedOnBehalfOfPermissionedAs} + targetWorkspace={opWorkspace?.() ?? $operatingWorkspace ?? ''} + isNew={newFlow || $initialPathStore == ''} + /> + <InvisibleRunsSetting + kind="flow" + bind:visibleToRunnerOnly={flowStore.val.visible_to_runner_only} + /> + <JobTokenScopesSetting bind:value={flowStore.val.job_token_scopes} kind="flow" /> + </div> + </TabContent> </div> - {/if} - - <!-- Debouncing Section --> - {#if customUi?.settingsTabs?.debouncing != false} - <DebounceLimit - size="xs" - fontClass="font-medium" - bind:debounce_delay_s={flowStore.val.value.debounce_delay_s} - bind:debounce_key={flowStore.val.value.debounce_key} - bind:debounce_args_to_accumulate={flowStore.val.value.debounce_args_to_accumulate} - bind:max_total_debouncing_time={flowStore.val.value.max_total_debouncing_time} - bind:max_total_debounces_amount={flowStore.val.value.max_total_debounces_amount} - schema={asSchema(flowStore.val.schema)} - placeholder={`$workspace/flow/${$pathStore}-$args[foo]`} - /> - {/if} - - <!-- Priority Section --> - <Toggle - textClass="font-medium" - size="xs" - disabled={!$enterpriseLicense || isCloudHosted()} - checked={flowStore.val.value.priority !== undefined && flowStore.val.value.priority > 0} - on:change={() => { - if (flowStore.val.value.priority) { - flowStore.val.value.priority = undefined - } else { - flowStore.val.value.priority = 100 - } - }} - eeOnly={true} - options={{ - right: `Label as high priority`, - rightTooltip: `All jobs scheduled by flows labeled as high priority take precedence over the other jobs in the jobs queue. Higher priority numbers are executed first.`, - rightDocumentationLink: 'https://www.windmill.dev/docs/flows/priority' - }} - > - {#snippet right()} - <input - type="number" - class={twMerge(inputBaseClass, inputBorderClass(), '!w-16 text-xs ml-4')} - disabled={flowStore.val.value.priority === undefined} - bind:value={flowStore.val.value.priority} - onfocus={bubble('focus')} - onchange={() => { - if (flowStore.val.value.priority && flowStore.val.value.priority > 100) { - flowStore.val.value.priority = 100 - } else if (flowStore.val.value.priority && flowStore.val.value.priority < 0) { - flowStore.val.value.priority = 0 - } - }} - /> - {/snippet} - </Toggle> - - <Toggle - textClass="font-medium" - size="xs" - disabled={!$enterpriseLicense} - checked={flowStore.val.value.delete_after_secs != null} - on:change={() => { - if (flowStore.val.value.delete_after_secs != null) { - flowStore.val.value.delete_after_secs = undefined - } else { - flowStore.val.value.delete_after_secs = 0 - } - }} - options={{ - right: 'Delete all step results after completion', - rightTooltip: `When enabled, the logs, arguments and results of all flow steps will be deleted after the specified delay once the flow completes. Set to 0 for immediate deletion. The deletion is irreversible.` - }} - eeOnly={true} - /> - {#if flowStore.val.value.delete_after_secs != null} - <div class="ml-6 mt-1"> - <SecondsInput - bind:seconds={flowStore.val.value.delete_after_secs} - disabled={!$enterpriseLicense} - size="sm" - /> - </div> - {/if} - - <div> - <Toggle - textClass="font-medium" - size="xs" - disabled={!$enterpriseLicense || isCloudHosted()} - checked={Boolean(flowStore.val.dedicated_worker)} - on:change={() => { - if (flowStore.val.dedicated_worker) { - flowStore.val.dedicated_worker = undefined - } else { - flowStore.val.dedicated_worker = true - } - }} - options={{ - right: 'Flow is run on dedicated workers', - rightTooltip: 'When enabled, the flow will be executed on a dedicated worker.', - rightDocumentationLink: - 'https://www.windmill.dev/docs/core_concepts/jobs#high-priority-jobs' - }} - eeOnly={true} - /> - - {#if flowStore.val.dedicated_worker} - <div class="mt-2"> - <Alert type="info" title="Require dedicated workers"> - A worker group needs to be configured to listen to this flow. Select it in the - dedicated workers section of the worker group configuration. - </Alert> - </div> - {/if} - </div> - {#if flowStore.val.schema && enableAi} - <AIFormSettings - bind:prompt={flowStore.val.schema.prompt_for_ai as string | undefined} - type="flow" - /> - {/if} - - <JobTokenScopesSetting - bind:value={flowStore.val.job_token_scopes} - kind="flow" - size="xs" - textClass="font-medium" - /> - </Section> + {/snippet} + </Tabs> </div> </FlowCard> </div> diff --git a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte index ee9d646419..f14711fc10 100644 --- a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte @@ -6,7 +6,7 @@ import { ScriptService, type Preview, type Script } from '$lib/gen' import { inferArgs } from '$lib/infer' import { Loader2, Save, DiffIcon, Settings } from 'lucide-svelte' - import ScriptAdvancedSettings from '$lib/components/ScriptAdvancedSettings.svelte' + import ScriptRuntimeSettings from '$lib/components/itemSettings/ScriptRuntimeSettings.svelte' import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte' import Popover from '$lib/components/Popover.svelte' import { @@ -348,17 +348,17 @@ /> <Drawer bind:this={settingsDrawer} size="600px"> - <DrawerContent title="Script settings" on:close={() => settingsDrawer?.closeDrawer()}> + <DrawerContent title="Runtime settings" on:close={() => settingsDrawer?.closeDrawer()}> {#if script} <div class="flex flex-col gap-4"> <p class="text-xs text-secondary"> These runtime settings are saved together with the script when you press Save. </p> - <ScriptAdvancedSettings {script} workspaceId={opWs} /> + <ScriptRuntimeSettings {script} workspaceId={opWs} /> </div> {/if} {#snippet actions()} - <Button variant="border" on:click={() => settingsDrawer?.closeDrawer()}>Done</Button> + <Button variant="default" on:click={() => settingsDrawer?.closeDrawer()}>Done</Button> {/snippet} </DrawerContent> </Drawer> diff --git a/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte index f9ed5f671d..9f644fb960 100644 --- a/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte +++ b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { Button, Drawer, DrawerContent } from '$lib/components/common' import Alert from '$lib/components/common/alert/Alert.svelte' - import ScriptAdvancedSettings from '$lib/components/ScriptAdvancedSettings.svelte' + import ScriptRuntimeSettings from '$lib/components/itemSettings/ScriptRuntimeSettings.svelte' import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte' import { ScriptService, type Script } from '$lib/gen' import { emptyString, sendUserToast } from '$lib/utils' @@ -17,7 +17,7 @@ let drawer: Drawer | undefined = $state() // cache_ignore_s3_path lives on NewScript but not the returned Script type; - // ScriptAdvancedSettings still edits it, so widen the local type to keep it. + // ScriptRuntimeSettings still edits it, so widen the local type to keep it. let script: (Script & { cache_ignore_s3_path?: boolean }) | undefined = $state(undefined) let loading = $state(false) let loadError = $state<string | undefined>(undefined) @@ -105,7 +105,7 @@ </script> <Drawer bind:this={drawer} size="600px"> - <DrawerContent title="Script settings" on:close={() => drawer?.closeDrawer()}> + <DrawerContent title="Runtime settings" on:close={() => drawer?.closeDrawer()}> {#if loading} <div class="center-center flex-col h-full text-tertiary"> <Loader2 class="animate-spin" size={16} /> @@ -118,8 +118,8 @@ </Alert> {#if current} <Button - size="xs" - variant="border" + unifiedSize="xs" + variant="default" on:click={() => current && load(current.path, current.hash)} > Retry @@ -136,7 +136,7 @@ Saving creates a new version of the workspace script with these runtime settings. The code is left unchanged. </p> - <ScriptAdvancedSettings {script} workspaceId={opWs} /> + <ScriptRuntimeSettings {script} workspaceId={opWs} /> </div> {/if} {#snippet actions()} diff --git a/frontend/src/lib/components/itemSettings/ActiveSettingChips.svelte b/frontend/src/lib/components/itemSettings/ActiveSettingChips.svelte new file mode 100644 index 0000000000..39c902d6fa --- /dev/null +++ b/frontend/src/lib/components/itemSettings/ActiveSettingChips.svelte @@ -0,0 +1,73 @@ +<script module lang="ts"> + /** Scrolls to a setting's row and flashes it, once its tab has rendered. */ + export function revealSetting(root: HTMLElement | undefined, setting: string) { + const row = root?.querySelector<HTMLElement>(`[data-setting="${setting}"]`) + if (!row) return + row.scrollIntoView({ block: 'center', behavior: 'smooth' }) + row.classList.add('setting-highlight') + setTimeout(() => row.classList.remove('setting-highlight'), 2000) + } +</script> + +<script lang="ts"> + import Badge from '$lib/components/common/badge/Badge.svelte' + import Popover from '$lib/components/meltComponents/Popover.svelte' + import type { ScriptSettingsBadge } from '$lib/components/scriptSettings' + + // The settings turned on in the Runtime and Permissions tabs, in the settings header so they + // show whichever tab is open. + + interface Props { + groups: { tab: string; badges: ScriptSettingsBadge[] }[] + /** Opens `tab` on the row whose `data-setting` is `setting`. */ + onopen: (tab: string, setting: string) => void + /** Chips shown before the rest collapse into a "+n" popover. */ + max?: number + } + + let { groups, onopen, max = 4 }: Props = $props() + + let chips = $derived(groups.flatMap((g) => g.badges.map((badge) => ({ tab: g.tab, badge })))) + let shown = $derived(chips.slice(0, max)) + let rest = $derived(chips.slice(max)) +</script> + +{#snippet chip({ tab, badge }: { tab: string; badge: ScriptSettingsBadge })} + <Badge + small + clickable + class="whitespace-nowrap" + icon={{ icon: badge.icon, position: 'left' }} + title="{badge.label}: {badge.detail}" + onclick={() => onopen(tab, badge.setting)} + > + {badge.short} + </Badge> +{/snippet} + +{#if chips.length > 0} + <div class="flex flex-row flex-nowrap items-center gap-1"> + {#each shown as c (c.badge.key)} + {@render chip(c)} + {/each} + {#if rest.length > 0} + <Popover + floatingConfig={{ strategy: 'absolute', placement: 'bottom-start' }} + contentClasses="border border-light rounded-lg shadow-lg p-2 surface-tertiary max-w-xs" + openOnHover + debounceDelay={150} + > + {#snippet trigger()} + <Badge small clickable>+{rest.length}</Badge> + {/snippet} + {#snippet content()} + <div class="flex flex-wrap gap-1"> + {#each rest as c (c.badge.key)} + {@render chip(c)} + {/each} + </div> + {/snippet} + </Popover> + {/if} + </div> +{/if} diff --git a/frontend/src/lib/components/itemSettings/CacheSetting.svelte b/frontend/src/lib/components/itemSettings/CacheSetting.svelte new file mode 100644 index 0000000000..2bb8ad97ad --- /dev/null +++ b/frontend/src/lib/components/itemSettings/CacheSetting.svelte @@ -0,0 +1,38 @@ +<script lang="ts"> + import Label from '$lib/components/Label.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import { SecondsInput } from '$lib/components/common' + import SettingToggle from './SettingToggle.svelte' + + interface Props { + kind: 'flow' | 'script' + ttl: number | undefined + ignoreS3Path: boolean | undefined + } + + let { kind, ttl = $bindable(), ignoreS3Path = $bindable() }: Props = $props() +</script> + +<SettingToggle + setting="cache" + label="Cache results" + tooltip={`The result of the ${kind} is kept for each set of inputs, and a run with the same inputs returns it instead of running again.`} + documentationLink={kind === 'flow' + ? 'https://www.windmill.dev/docs/flows/cache#cache-flows' + : 'https://www.windmill.dev/docs/core_concepts/caching'} + checked={ttl !== undefined && ttl > 0} + onchange={(on) => (ttl = on ? 300 : undefined)} +> + <Label label="How long to keep cache valid"> + <SecondsInput bind:seconds={ttl} /> + </Label> + <Toggle + size="2xs" + bind:checked={() => ignoreS3Path, (v) => (ignoreS3Path = v || undefined)} + options={{ + right: 'Ignore S3 object paths', + rightTooltip: + 'Two S3 objects with the same content hit the same cache entry, whatever their path.' + }} + /> +</SettingToggle> diff --git a/frontend/src/lib/components/itemSettings/ConcurrencyLimitSetting.svelte b/frontend/src/lib/components/itemSettings/ConcurrencyLimitSetting.svelte new file mode 100644 index 0000000000..bf896b3a74 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/ConcurrencyLimitSetting.svelte @@ -0,0 +1,71 @@ +<script lang="ts"> + import Label from '$lib/components/Label.svelte' + import Tooltip from '$lib/components/Tooltip.svelte' + import { SecondsInput } from '$lib/components/common' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import { enterpriseLicense } from '$lib/stores' + import SettingToggle from './SettingToggle.svelte' + + interface Props { + kind: 'flow' | 'script' + limit: number | undefined + timeWindowS: number | undefined + concurrencyKey: string | undefined + keyPlaceholder: string + } + + let { + kind, + limit = $bindable(), + timeWindowS = $bindable(), + concurrencyKey = $bindable(), + keyPlaceholder + }: Props = $props() + + let disabled = $derived(!$enterpriseLicense) +</script> + +<SettingToggle + setting="concurrency" + label="Concurrency limit" + tooltip={`Caps how many runs of this ${kind} can start within a time window. The runs over the limit wait in the queue.`} + documentationLink={kind === 'flow' + ? 'https://www.windmill.dev/docs/flows/concurrency_limit' + : 'https://www.windmill.dev/docs/core_concepts/concurrency_limits'} + eeOnly + {disabled} + checked={limit !== undefined && limit > 0} + onchange={(on) => { + if (on) { + limit = 1 + } else { + limit = undefined + // A flow keeps its window and key in its value when the limit goes; a script drops them. + if (kind === 'script') { + timeWindowS = undefined + concurrencyKey = undefined + } + } + }} +> + <Label label="Max number of executions within the time window"> + <TextInput class="w-24" bind:value={limit} inputProps={{ type: 'number', min: 1, disabled }} /> + </Label> + <Label label="Time window in seconds"> + <SecondsInput {disabled} bind:seconds={timeWindowS} /> + </Label> + <Label label="Custom concurrency key (optional)"> + {#snippet header()} + <Tooltip + documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits#custom-concurrency-key" + > + Concurrency keys are global. Use `$workspace` to make one workspace specific, and + `$args[name_of_arg]` to use an argument's value. + </Tooltip> + {/snippet} + <TextInput + bind:value={concurrencyKey} + inputProps={{ type: 'text', disabled, placeholder: keyPlaceholder }} + /> + </Label> +</SettingToggle> diff --git a/frontend/src/lib/components/itemSettings/DedicatedWorkersSetting.svelte b/frontend/src/lib/components/itemSettings/DedicatedWorkersSetting.svelte new file mode 100644 index 0000000000..592a8e21a3 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/DedicatedWorkersSetting.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import Alert from '$lib/components/common/alert/Alert.svelte' + import { isCloudHosted } from '$lib/cloud' + import { enterpriseLicense } from '$lib/stores' + import SettingToggle from './SettingToggle.svelte' + + interface Props { + kind: 'flow' | 'script' + dedicatedWorker: boolean | undefined + /** Why this item cannot run on dedicated workers, e.g. its language. */ + unsupportedReason?: string + } + + let { kind, dedicatedWorker = $bindable(), unsupportedReason }: Props = $props() +</script> + +{#snippet reason()} + {unsupportedReason} +{/snippet} + +<!-- Not available on the cloud, where the toggle could only ever be disabled. --> +{#if !isCloudHosted()} + <SettingToggle + setting="dedicated-worker" + label="Dedicated workers" + tooltip={kind === 'flow' + ? 'The steps of the flow run on workers dedicated to it, which keep them warm so there is no cold start.' + : 'The script runs on workers dedicated to it, which keep it warm so there is no cold start. A dedicated worker can reach more than 1500 runs per second.'} + documentationLink="https://www.windmill.dev/docs/core_concepts/dedicated_workers" + eeOnly + disabled={!$enterpriseLicense || (!!unsupportedReason && !dedicatedWorker)} + checked={!!dedicatedWorker} + onchange={(on) => (dedicatedWorker = on || undefined)} + hint={unsupportedReason ? reason : undefined} + > + <Alert type="info" title="Requires a dedicated worker group" size="xs"> + A worker group must listen to this {kind}. Select it in the dedicated workers section of the + worker group configuration. + </Alert> + </SettingToggle> +{/if} diff --git a/frontend/src/lib/components/itemSettings/DeleteAfterUseSetting.svelte b/frontend/src/lib/components/itemSettings/DeleteAfterUseSetting.svelte new file mode 100644 index 0000000000..0ac1449230 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/DeleteAfterUseSetting.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + import { SecondsInput } from '$lib/components/common' + import { enterpriseLicense } from '$lib/stores' + import SettingToggle from './SettingToggle.svelte' + + interface Props { + kind: 'flow' | 'script' + deleteAfterSecs: number | undefined + } + + let { kind, deleteAfterSecs = $bindable() }: Props = $props() +</script> + +<SettingToggle + setting="lifetime" + label="Delete after use" + tooltip={kind === 'flow' + ? 'The logs, arguments and results of every step are deleted this long after the flow completes. 0 deletes them right away. The deletion is irreversible.' + : 'The logs, arguments and result of each run are deleted this long after it completes and returns its result. 0 deletes them right away. Only applies to synchronous webhooks and to the script run as a flow step. The deletion is irreversible.'} + documentationLink={kind === 'script' + ? 'https://www.windmill.dev/docs/script_editor/settings#delete-after-use' + : undefined} + eeOnly + disabled={!$enterpriseLicense} + checked={deleteAfterSecs != null} + onchange={(on) => (deleteAfterSecs = on ? 0 : undefined)} +> + <SecondsInput bind:seconds={deleteAfterSecs} disabled={!$enterpriseLicense} /> +</SettingToggle> diff --git a/frontend/src/lib/components/itemSettings/InvisibleRunsSetting.svelte b/frontend/src/lib/components/itemSettings/InvisibleRunsSetting.svelte new file mode 100644 index 0000000000..883ba5b816 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/InvisibleRunsSetting.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import SettingToggle from './SettingToggle.svelte' + + interface Props { + kind: 'flow' | 'script' + visibleToRunnerOnly: boolean | undefined + } + + let { kind, visibleToRunnerOnly = $bindable() }: Props = $props() +</script> + +<SettingToggle + setting="invisible-runs" + label="Make runs invisible to others" + tooltip={`Manual runs of this ${kind} are visible only to the user who ran them, owners included. Whoever runs it can override this from the advanced menu of the run form.`} + documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#invisible-runs" + checked={!!visibleToRunnerOnly} + onchange={(on) => (visibleToRunnerOnly = on || undefined)} +/> diff --git a/frontend/src/lib/components/itemSettings/MuteErrorHandlerSetting.svelte b/frontend/src/lib/components/itemSettings/MuteErrorHandlerSetting.svelte new file mode 100644 index 0000000000..bbb9c37514 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/MuteErrorHandlerSetting.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + import { toggleWorkspaceErrorHandler } from '$lib/components/details/errorHandlerToggle' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { enterpriseLicense } from '$lib/stores' + import SettingToggle from './SettingToggle.svelte' + + interface Props { + kind: 'flow' | 'script' + path: string + /** Also sent with the next deploy. */ + muted: boolean | undefined + } + + let { kind, path, muted = $bindable() }: Props = $props() + + const operatingWorkspace = useOperatingWorkspace() + + // The toggle shows the click until the request settles, then the muted state it left. + let pending: boolean | undefined = $state(undefined) + + async function toggle() { + const next = await toggleWorkspaceErrorHandler($operatingWorkspace, kind, path, muted) + if (next !== undefined) muted = next + pending = undefined + } +</script> + +{#snippet eeHint()} + Enterprise Edition only. +{/snippet} + +<SettingToggle + setting="mute-error-handler" + label="Mute workspace error handler" + tooltip={`Failures of this ${kind} do not trigger the workspace error handler. Applies to the deployed ${kind} right away.`} + documentationLink="https://www.windmill.dev/docs/core_concepts/error_handling#workspace-error-handler" + checked={pending ?? !!muted} + onchange={(on) => { + pending = on + toggle() + }} + hint={$enterpriseLicense ? undefined : eeHint} +/> diff --git a/frontend/src/lib/components/itemSettings/PrioritySetting.svelte b/frontend/src/lib/components/itemSettings/PrioritySetting.svelte new file mode 100644 index 0000000000..99a36543e4 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/PrioritySetting.svelte @@ -0,0 +1,53 @@ +<script lang="ts"> + import Label from '$lib/components/Label.svelte' + import Tooltip from '$lib/components/Tooltip.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import { isCloudHosted } from '$lib/cloud' + import { enterpriseLicense } from '$lib/stores' + import SettingToggle from './SettingToggle.svelte' + + interface Props { + kind: 'flow' | 'script' + priority: number | undefined + } + + let { kind, priority = $bindable() }: Props = $props() +</script> + +<!-- Not available on the cloud, where the toggle could only ever be disabled. --> +{#if !isCloudHosted()} + <SettingToggle + setting="priority" + label="High priority" + tooltip={kind === 'flow' + ? 'The jobs of this flow and of its steps go ahead of the other jobs in the queue.' + : 'The jobs of this script go ahead of the other jobs in the queue.'} + documentationLink={kind === 'flow' + ? 'https://www.windmill.dev/docs/flows/priority' + : 'https://www.windmill.dev/docs/core_concepts/jobs#high-priority-jobs'} + eeOnly + disabled={!$enterpriseLicense} + checked={priority !== undefined && priority > 0} + onchange={(on) => (priority = on ? 100 : undefined)} + > + <Label label="Priority number"> + {#snippet header()} + <Tooltip>From 0 to 100. Higher numbers run first.</Tooltip> + {/snippet} + <TextInput + class="w-24" + bind:value={priority} + inputProps={{ + type: 'number', + min: 0, + max: 100, + disabled: !$enterpriseLicense, + onchange: () => { + if (priority != undefined && priority > 100) priority = 100 + else if (priority != undefined && priority < 0) priority = 0 + } + }} + /> + </Label> + </SettingToggle> +{/if} diff --git a/frontend/src/lib/components/itemSettings/RunOnBehalfOfSetting.svelte b/frontend/src/lib/components/itemSettings/RunOnBehalfOfSetting.svelte new file mode 100644 index 0000000000..815254fab4 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/RunOnBehalfOfSetting.svelte @@ -0,0 +1,131 @@ +<script lang="ts"> + import OnBehalfOfSelector, { + type OnBehalfOfChoice + } from '$lib/components/OnBehalfOfSelector.svelte' + import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + import { userStore } from '$lib/stores' + import { AlertTriangle } from 'lucide-svelte' + import SettingToggle from './SettingToggle.svelte' + import { canPreserveOnBehalfOf, deployedPermissionedAs, WM_DEPLOYERS_GROUP } from './onBehalfOf' + + interface Props { + kind: 'flow' | 'script' + /** Email of the user whose access the runs use; undefined uses whoever starts each run. */ + email: string | undefined + /** `u/username` of that user. */ + permissionedAs: string | undefined + /** Sent as `preserve_on_behalf_of`: keep the picked user rather than the deployer. */ + preserve: boolean + /** The selector's choice. Owned by the host, so it outlives this row's remounts: while it is + * undefined the selector picks the deployed user, which would override a later pick. */ + choice: OnBehalfOfChoice + /** The deployed identity, which the "target" choice keeps. */ + savedEmail: string | undefined + savedPermissionedAs: string | undefined + targetWorkspace: string + /** Never deployed, so no deployed identity can change. */ + isNew?: boolean + } + + let { + kind, + email = $bindable(), + permissionedAs = $bindable(), + preserve = $bindable(), + choice = $bindable(), + savedEmail, + savedPermissionedAs, + targetWorkspace, + isNew = false + }: Props = $props() + + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) + let canPreserve = $derived(canPreserveOnBehalfOf(actingUser)) + let myPermissionedAs = $derived($userStore?.username ? `u/${$userStore.username}` : undefined) + + let enabled = $derived(!!email) + let deployPermissionedAs = $derived( + enabled ? deployedPermissionedAs(actingUser, permissionedAs) : undefined + ) + // Whoever cannot keep another user's identity deploys the item as themselves. + let chosenUser = $derived( + (canPreserve ? (permissionedAs ?? email) : deployPermissionedAs) ?? 'the chosen user' + ) + let willChange = $derived( + !isNew && + !!savedPermissionedAs && + !!deployPermissionedAs && + deployPermissionedAs !== savedPermissionedAs + ) +</script> + +{#snippet valueHint()} + Lets people run this {kind} with resources and variables they can't access themselves. +{/snippet} + +<SettingToggle + setting="on-behalf-of" + label="Run on behalf of" + tooltip={`Use it to share a ${kind} that touches secrets: people run it without ever seeing the credentials. Or pick a dedicated user, like billing-bot, so the ${kind} can only reach that user's folders and resources.`} + documentationLink="https://www.windmill.dev/docs/core_concepts/roles_and_permissions#run-on-behalf-of" + hint={valueHint} + checked={enabled} + onchange={(on) => { + if (on) { + email = $userStore?.email + permissionedAs = myPermissionedAs + } else { + email = undefined + permissionedAs = undefined + preserve = false + choice = undefined + } + }} +> + <div class="flex flex-wrap items-center gap-1.5"> + <OnBehalfOfSelector + {targetWorkspace} + targetValue={savedEmail} + selected={choice} + onSelect={(c, details) => { + choice = c + if (c === 'me') { + email = $userStore?.email + permissionedAs = myPermissionedAs + preserve = false + } else if (c === 'target') { + // Keep the saved pair. An item that has no recorded principal yet sends the + // email alone and the backend derives one from it. + email = savedEmail + permissionedAs = savedPermissionedAs + preserve = true + } else if (c === 'custom' && details) { + email = details.email + permissionedAs = details.permissionedAs + preserve = true + } + }} + {kind} + {canPreserve} + customValue={choice === 'custom' ? email : undefined} + isDeployment={false} + readOnlyWithoutPreserve + /> + {#if willChange} + <AlertTriangle size={14} class="text-yellow-500" /> + <span class="text-2xs text-yellow-600 dark:text-yellow-400" + >Changes from <strong>{savedPermissionedAs}</strong> on deploy</span + > + {/if} + </div> + {#if !canPreserve} + <div class="text-2xs text-secondary"> + Only workspace admins and members of {WM_DEPLOYERS_GROUP} can pick another user. + </div> + {/if} + <div class="text-2xs text-secondary"> + Every run uses the access of {chosenUser}. Who started it is still recorded, and the code reads + their email from <code>WM_END_USER_EMAIL</code>. + </div> +</SettingToggle> diff --git a/frontend/src/lib/components/itemSettings/ScriptRuntimeSettings.svelte b/frontend/src/lib/components/itemSettings/ScriptRuntimeSettings.svelte new file mode 100644 index 0000000000..6fb657b10e --- /dev/null +++ b/frontend/src/lib/components/itemSettings/ScriptRuntimeSettings.svelte @@ -0,0 +1,140 @@ +<script lang="ts"> + import { Alert, Button, SecondsInput } from '$lib/components/common' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import DebounceLimit from '$lib/components/flows/DebounceLimit.svelte' + import { isCloudHosted } from '$lib/cloud' + import type { Schema } from '$lib/common' + import type { ScriptAdvancedSettingsFields } from '$lib/components/scriptSettings' + import { Plus, X } from 'lucide-svelte' + import SettingGroup from '$lib/components/itemSettings/SettingGroup.svelte' + import SettingToggle from './SettingToggle.svelte' + import WorkerTagSetting from './WorkerTagSetting.svelte' + import ConcurrencyLimitSetting from './ConcurrencyLimitSetting.svelte' + import CacheSetting from './CacheSetting.svelte' + import PrioritySetting from './PrioritySetting.svelte' + import DedicatedWorkersSetting from './DedicatedWorkersSetting.svelte' + import DeleteAfterUseSetting from './DeleteAfterUseSetting.svelte' + + interface Props { + script: ScriptAdvancedSettingsFields + /** Workspace to read worker tags from; the picker defaults to the operating one. */ + workspaceId?: string + tagPlaceholder?: string + /** Seeded when the custom timeout is turned on. */ + defaultTimeout?: number + } + + let { script = $bindable(), workspaceId, tagPlaceholder, defaultTimeout = 300 }: Props = $props() + + const DEDICATED_WORKER_LANGUAGES = ['bun', 'bunnative', 'python3', 'deno'] + + // The list opens before a variable is typed, so the toggle cannot read only `envs`. + let addingEnvs = $state(false) +</script> + +<div class="flex flex-col gap-8"> + <SettingGroup label="Execution policy" class="flex flex-col gap-6"> + <SettingToggle + setting="timeout" + label="Custom timeout" + tooltip="Used instead of the instance timeout, which it cannot exceed." + documentationLink="https://www.windmill.dev/docs/script_editor/settings#timeout" + checked={!!script.timeout} + onchange={(on) => (script.timeout = on ? defaultTimeout : undefined)} + > + <SecondsInput bind:seconds={script.timeout} /> + </SettingToggle> + <ConcurrencyLimitSetting + kind="script" + bind:limit={script.concurrent_limit} + bind:timeWindowS={script.concurrency_time_window_s} + bind:concurrencyKey={script.concurrency_key} + keyPlaceholder={`$workspace/script/${script.path ?? ''}-$args[foo]`} + /> + <DebounceLimit + bind:debounce_delay_s={script.debounce_delay_s} + bind:debounce_key={script.debounce_key} + bind:debounce_args_to_accumulate={script.debounce_args_to_accumulate} + bind:max_total_debouncing_time={script.max_total_debouncing_time} + bind:max_total_debounces_amount={script.max_total_debounces_amount} + schema={script.schema as Schema} + placeholder={`$workspace/script/${script.path ?? ''}-$args[foo]`} + /> + <CacheSetting + kind="script" + bind:ttl={script.cache_ttl} + bind:ignoreS3Path={script.cache_ignore_s3_path} + /> + <PrioritySetting kind="script" bind:priority={script.priority} /> + <SettingToggle + setting="perpetual" + label="Perpetual script" + tooltip="The script restarts as soon as it ends, unless it was cancelled." + documentationLink="https://www.windmill.dev/docs/script_editor/perpetual_scripts" + checked={!!script.restart_unless_cancelled} + onchange={(on) => (script.restart_unless_cancelled = on || undefined)} + /> + <DeleteAfterUseSetting kind="script" bind:deleteAfterSecs={script.delete_after_secs} /> + </SettingGroup> + + <SettingGroup label="Workers" class="flex flex-col gap-6"> + <WorkerTagSetting + kind="script" + bind:tag={script.tag} + placeholder={tagPlaceholder} + {workspaceId} + /> + <DedicatedWorkersSetting + kind="script" + bind:dedicatedWorker={script.dedicated_worker} + unsupportedReason={script.language && !DEDICATED_WORKER_LANGUAGES.includes(script.language) + ? 'Only for Python, Bun and Deno scripts. Other languages start no full runtime, so they already run about as fast.' + : undefined} + /> + {#if !isCloudHosted()} + <SettingToggle + setting="envs" + label="Custom env variables" + tooltip="Static environment variables passed to the deployed script." + documentationLink="https://www.windmill.dev/docs/script_editor/custom_environment_variables" + checked={addingEnvs || (script.envs?.length ?? 0) > 0} + onchange={(on) => { + addingEnvs = on + script.envs = on ? [''] : [] + }} + > + <Alert type="info" title="Not passed in previews" size="xs"> + Only the deployed script gets these variables. + </Alert> + {#each script.envs ?? [] as _, i (i)} + <div class="flex flex-row items-center gap-1 max-w-md"> + {#if script.envs} + <TextInput + bind:value={script.envs[i]} + inputProps={{ type: 'text', placeholder: 'KEY=VALUE' }} + /> + {/if} + <Button + variant="subtle" + unifiedSize="md" + iconOnly + startIcon={{ icon: X }} + title="Remove variable" + onclick={() => (script.envs = script.envs?.filter((_, j) => j !== i))} + /> + </div> + {/each} + <div class="flex"> + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Plus }} + onclick={() => (script.envs = [...(script.envs ?? []), ''])} + > + Add variable + </Button> + </div> + </SettingToggle> + {/if} + </SettingGroup> +</div> diff --git a/frontend/src/lib/components/itemSettings/SettingGroup.svelte b/frontend/src/lib/components/itemSettings/SettingGroup.svelte new file mode 100644 index 0000000000..993e4b3a60 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/SettingGroup.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import type { Snippet } from 'svelte' + + interface Props { + label: string + /** Applied to the wrapper of the group's settings. */ + class?: string + children?: Snippet + } + + let { label, class: clazz = '', children }: Props = $props() +</script> + +<!-- A group of settings whose rows explain themselves: the label only names the group, so it stays + small and quiet instead of a section heading. --> +<section class="flex flex-col gap-3"> + <div class="text-2xs font-medium uppercase tracking-wide text-secondary">{label}</div> + <div class={clazz}> + {@render children?.()} + </div> +</section> diff --git a/frontend/src/lib/components/itemSettings/SettingIcons.svelte b/frontend/src/lib/components/itemSettings/SettingIcons.svelte new file mode 100644 index 0000000000..289fee3e56 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/SettingIcons.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import Popover from '$lib/components/Popover.svelte' + import type { ScriptSettingsBadge } from '$lib/components/scriptSettings' + + interface Props { + badges: ScriptSettingsBadge[] + } + + let { badges }: Props = $props() +</script> + +<!-- The configured settings of a tab, drawn on the tab the way the step "Run settings" tab + draws its own. --> +{#if badges.length > 0} + <div class="flex flex-row items-center gap-1 text-secondary"> + {#each badges as badge (badge.key)} + {@const Icon = badge.icon} + <Popover notClickable> + <Icon size={12} aria-hidden="true" /> + <span class="sr-only">{badge.label}: {badge.detail}</span> + {#snippet text()} + <span class="font-semibold">{badge.label}</span> · {badge.detail} + {/snippet} + </Popover> + {/each} + </div> +{/if} diff --git a/frontend/src/lib/components/itemSettings/SettingToggle.svelte b/frontend/src/lib/components/itemSettings/SettingToggle.svelte new file mode 100644 index 0000000000..3b53644692 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/SettingToggle.svelte @@ -0,0 +1,90 @@ +<script lang="ts"> + import type { Snippet } from 'svelte' + import Toggle from '$lib/components/Toggle.svelte' + import { slideDynamic } from '$lib/transitions' + + // One optional setting of a flow, script, app or step: a toggle, and the setting's + // parameters under it while it is on. The settings surfaces build their rows from this so + // they keep one spacing and indent. + + interface Props { + label: string + tooltip?: string + documentationLink?: string + /** Whether the setting is configured; its parameters show while it is. */ + checked: boolean | undefined + /** The new state, for the host to write or clear the setting's value. */ + onchange?: (checked: boolean) => void + disabled?: boolean + /** Badges the toggle "EE" while it is disabled. */ + eeOnly?: boolean + /** Lets a host scroll to the row: `[data-setting="<key>"]`. */ + setting?: string + aiId?: string + aiDescription?: string + /** Shown under the row whatever its state, e.g. why it is disabled. */ + hint?: Snippet + /** The setting's parameters. */ + children?: Snippet + } + + let { + label, + tooltip, + documentationLink, + checked, + onchange, + disabled = false, + eeOnly = false, + setting, + aiId, + aiDescription, + hint, + children + }: Props = $props() + + // Hosts read `checked` from the value, and a value being typed passes through states that read + // as off (an emptied field, a 0, an empty expression). Once its parameters have focus, they + // stay open until the toggle is clicked; the toggle itself shows whether the value is set. + let editing = $state(false) + // Bumped each time the toggle turns the row on, so its parameters remount on the value the host + // just wrote even when the row was already open: an editor reads its code once, at mount. + let generation = $state(0) +</script> + +<div class="flex flex-col gap-2" data-setting={setting}> + <Toggle + size="xs" + bind:checked={ + () => checked, + (v) => { + editing = false + // The host writes the value here, before the row opens. A click runs microtasks between + // the input's listeners, so a `change` listener would come after the parameters mounted + // on the old value, and an editor that reads its code once at mount would open empty. + onchange?.(!!v) + if (v) generation++ + } + } + {disabled} + {eeOnly} + {aiId} + {aiDescription} + options={{ right: label, rightTooltip: tooltip, rightDocumentationLink: documentationLink }} + /> + {#if hint} + <!-- pl-9 lines everything under the label: an xs toggle is 28px wide plus its 8px gap. --> + <div class="pl-9 text-2xs text-secondary">{@render hint()}</div> + {/if} + {#if (checked || editing) && children} + <!-- The slide clamps this box's height. A flex column would shrink an `overflow` child (an + editor) to fit the clamp, so the slide would measure it short and jump at the end. --> + <div transition:slideDynamic onfocusin={() => (editing = true)}> + {#key generation} + <div class="flex flex-col gap-2 pl-9"> + {@render children()} + </div> + {/key} + </div> + {/if} +</div> diff --git a/frontend/src/lib/components/itemSettings/WorkerTagSetting.svelte b/frontend/src/lib/components/itemSettings/WorkerTagSetting.svelte new file mode 100644 index 0000000000..497cb2bec3 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/WorkerTagSetting.svelte @@ -0,0 +1,57 @@ +<script lang="ts"> + import Toggle from '$lib/components/Toggle.svelte' + import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte' + import SettingToggle from './SettingToggle.svelte' + + interface Props { + kind: 'flow' | 'script' + tag: string | undefined + /** Flows only: let steps that declare their own tag keep it. */ + preserveStepTags?: boolean | undefined + placeholder?: string + workspaceId?: string + } + + let { + kind, + tag = $bindable(), + preserveStepTags = $bindable(), + placeholder, + workspaceId + }: Props = $props() + + // The picker opens before a tag is picked, so the toggle cannot read only `tag`. + let picking = $state(false) + let checked = $derived(picking || !!tag) +</script> + +<SettingToggle + setting="worker-group" + label="Worker group tag" + tooltip={kind === 'flow' + ? "Every step of the flow runs on the workers listening to this tag (queue), whatever the step's own tag. Without one, the flow's control steps run with the default 'flow' tag and each step with its own." + : 'The script runs on the workers listening to this tag (queue), for instance a highmem or gpu worker group.'} + documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups" + {checked} + onchange={(on) => { + picking = on + if (!on) { + tag = undefined + preserveStepTags = undefined + } + }} +> + <WorkerTagPicker bind:tag {placeholder} {workspaceId} /> + {#if kind === 'flow' && tag} + <Toggle + size="2xs" + bind:checked={preserveStepTags} + options={{ + right: 'Preserve step worker tags', + rightTooltip: + 'Steps that declare their own worker tag run on it instead of the flow tag. Steps without one still inherit the flow tag.', + rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/worker_groups' + }} + /> + {/if} +</SettingToggle> diff --git a/frontend/src/lib/components/itemSettings/onBehalfOf.test.ts b/frontend/src/lib/components/itemSettings/onBehalfOf.test.ts new file mode 100644 index 0000000000..04c55d3666 --- /dev/null +++ b/frontend/src/lib/components/itemSettings/onBehalfOf.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { deployedPermissionedAs, keepsDeployedOnBehalfOf } from './onBehalfOf' + +// Who a deploy leaves the item running on behalf of: only admins and wm_deployers keep another user. +describe('onBehalfOf', () => { + const admin = { username: 'admin', is_admin: true } + const deployer = { username: 'dep', groups: ['wm_deployers'] } + const member = { username: 'alice' } + + it('keeps the held user only for a deployer who may preserve', () => { + expect(deployedPermissionedAs(admin, 'u/bot')).toBe('u/bot') + expect(deployedPermissionedAs(deployer, 'u/bot')).toBe('u/bot') + expect(deployedPermissionedAs(admin, undefined)).toBe('u/admin') + expect(deployedPermissionedAs(member, 'u/bot')).toBe('u/alice') + }) + + it('picks the deployed user only when nothing is picked and run-as is on', () => { + expect(keepsDeployedOnBehalfOf(admin, undefined, 'bot@x.dev', 'bot@x.dev')).toBe(true) + expect(keepsDeployedOnBehalfOf(deployer, undefined, 'me@x.dev', 'bot@x.dev')).toBe(true) + expect(keepsDeployedOnBehalfOf(admin, 'me', 'me@x.dev', 'bot@x.dev')).toBe(false) + expect(keepsDeployedOnBehalfOf(admin, undefined, undefined, 'bot@x.dev')).toBe(false) + expect(keepsDeployedOnBehalfOf(admin, undefined, 'me@x.dev', undefined)).toBe(false) + expect(keepsDeployedOnBehalfOf(member, undefined, 'bot@x.dev', 'bot@x.dev')).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/itemSettings/onBehalfOf.ts b/frontend/src/lib/components/itemSettings/onBehalfOf.ts new file mode 100644 index 0000000000..803fa5f45a --- /dev/null +++ b/frontend/src/lib/components/itemSettings/onBehalfOf.ts @@ -0,0 +1,43 @@ +export const WM_DEPLOYERS_GROUP = 'wm_deployers' + +type DeployingUser = + | { + username?: string + email?: string + is_admin?: boolean + is_super_admin?: boolean + groups?: string[] + } + | undefined + +/** Who may keep, or pick, another user's identity on deploy. For anyone else the backend deploys + * the item on behalf of the deployer, whatever the request says. */ +export function canPreserveOnBehalfOf(user: DeployingUser): boolean { + return ( + !!user?.is_admin || + !!user?.is_super_admin || + (user?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) + ) +} + +/** Whether the deployed user should be picked, as the run-as selector does once it shows with + * nothing picked. The Runtime tab applies it too, so a deploy that only changes a runtime + * setting keeps sending `preserve_on_behalf_of`. */ +export function keepsDeployedOnBehalfOf( + user: DeployingUser, + choice: string | undefined, + email: string | undefined, + savedEmail: string | undefined +): boolean { + return choice === undefined && !!email && !!savedEmail && canPreserveOnBehalfOf(user) +} + +/** The `u/username` a deploy leaves the item running on behalf of: the held user for a deployer + * who may preserve, the deployer for anyone else. */ +export function deployedPermissionedAs( + user: DeployingUser, + permissionedAs: string | undefined +): string | undefined { + if (permissionedAs && canPreserveOnBehalfOf(user)) return permissionedAs + return user?.username ? `u/${user.username}` : undefined +} diff --git a/frontend/src/lib/components/scriptSettings.test.ts b/frontend/src/lib/components/scriptSettings.test.ts index f010e3863a..92d8722a2f 100644 --- a/frontend/src/lib/components/scriptSettings.test.ts +++ b/frontend/src/lib/components/scriptSettings.test.ts @@ -16,7 +16,7 @@ describe('getActiveScriptSettingsBadges', () => { priority: 50, tag: 'gpu' }).map((b) => b.key) - expect(keys).toEqual(['concurrency', 'cache', 'timeout', 'priority', 'tag']) + expect(keys).toEqual(['timeout', 'concurrency', 'cache', 'priority', 'tag']) }) it('treats a zero/absent priority and non-positive debounce as inactive', () => { diff --git a/frontend/src/lib/components/scriptSettings.ts b/frontend/src/lib/components/scriptSettings.ts index 4a3b3ad5b8..6782c526bc 100644 --- a/frontend/src/lib/components/scriptSettings.ts +++ b/frontend/src/lib/components/scriptSettings.ts @@ -1,19 +1,25 @@ import { - Gauge, - Database, - Timer, - Hourglass, - Repeat, - Cpu, - Trash2, ChevronsUp, - Tag + CircleStop, + Combine, + CornerDownLeft, + Cpu, + Database, + EyeOff, + FolderOpen, + Gauge, + KeyRound, + Repeat, + Tag, + Timer, + Trash2, + UserCog } from 'lucide-svelte' -import type { ScriptLang } from '$lib/gen' +import type { FlowValue, ScriptLang } from '$lib/gen' -// Subset of Script/NewScript fields that make up the "advanced runtime settings" -// surfaced both in the standalone script editor and, via the mini settings drawer, -// from within the flow editor for workspace-script steps. +// Subset of Script/NewScript fields that make up the runtime settings surfaced both in the +// standalone script editor and, via the mini settings drawer, from within the flow editor for +// workspace-script steps. export type ScriptAdvancedSettingsFields = { path?: string language?: ScriptLang @@ -34,27 +40,94 @@ export type ScriptAdvancedSettingsFields = { dedicated_worker?: boolean delete_after_secs?: number priority?: number + envs?: string[] } export type ScriptSettingsBadge = { key: string label: string + /** The label in a word or two, for the chips in the settings header. */ + short: string + /** The `data-setting` of the setting's row in its tab. */ + setting: string icon: any detail: string } -// Compute the list of active advanced settings for a script, used to render -// at-a-glance badges in the editor top bar and in the flow drawers. +/** Runtime settings only a flow has. */ +export type FlowOnlyRuntimeFields = { + skip_expr?: string + early_return?: string + same_worker?: boolean +} + +/** A flow's runtime settings, which it splits between itself and its value, in the shape the + * badges read. */ +export function flowRuntimeFields(flow: { + tag?: string + dedicated_worker?: boolean + value: FlowValue +}): ScriptAdvancedSettingsFields & FlowOnlyRuntimeFields { + const v = flow.value + return { + tag: flow.tag, + dedicated_worker: flow.dedicated_worker, + concurrent_limit: v.concurrent_limit, + concurrency_time_window_s: v.concurrency_time_window_s, + cache_ttl: v.cache_ttl, + debounce_delay_s: v.debounce_delay_s, + delete_after_secs: v.delete_after_secs, + priority: v.priority, + skip_expr: v.skip_expr, + early_return: v.early_return, + same_worker: v.same_worker + } +} + +// The active runtime settings of a script or flow, in the order their Runtime tab lists them, +// for the at-a-glance badges of the editor top bar, the settings header and tabs, and the flow drawers. export function getActiveScriptSettingsBadges( - settings: ScriptAdvancedSettingsFields | undefined + settings: (ScriptAdvancedSettingsFields & FlowOnlyRuntimeFields) | undefined ): ScriptSettingsBadge[] { if (!settings) return [] const badges: ScriptSettingsBadge[] = [] + if (settings.skip_expr) { + badges.push({ + key: 'early_stop', + short: 'Stop if', + setting: 'early-stop', + label: 'Stop flow if', + icon: CircleStop, + detail: 'Stops early when the inputs match the expression' + }) + } + if (settings.early_return) { + badges.push({ + key: 'early_return', + short: 'Early return', + setting: 'early-return', + label: 'Early return', + icon: CornerDownLeft, + detail: `Sync webhooks return the result of step ${settings.early_return}` + }) + } // Non-positive concurrent_limit / timeout are treated as unset by the runtime // (legacy zero rows), so don't surface them as active settings. + if (settings.timeout != undefined && settings.timeout > 0) { + badges.push({ + key: 'timeout', + short: 'Timeout', + setting: 'timeout', + label: 'Timeout', + icon: Timer, + detail: `${settings.timeout}s` + }) + } if (settings.concurrent_limit != undefined && settings.concurrent_limit > 0) { badges.push({ key: 'concurrency', + short: 'Concurrency', + setting: 'concurrency', label: 'Concurrency', icon: Gauge, detail: `Max ${settings.concurrent_limit} execution${ @@ -66,49 +139,51 @@ export function getActiveScriptSettingsBadges( }` }) } + if (settings.debounce_delay_s != undefined && settings.debounce_delay_s > 0) { + badges.push({ + key: 'debounce', + short: 'Debounce', + setting: 'debounce', + label: 'Debounce', + icon: Combine, + detail: `Debounced by ${settings.debounce_delay_s}s` + }) + } if (settings.cache_ttl != undefined && settings.cache_ttl > 0) { badges.push({ key: 'cache', + short: 'Cache', + setting: 'cache', label: 'Cache', icon: Database, detail: `Cached for ${settings.cache_ttl}s` }) } - if (settings.timeout != undefined && settings.timeout > 0) { + if (settings.priority != undefined && settings.priority > 0) { badges.push({ - key: 'timeout', - label: 'Timeout', - icon: Timer, - detail: `${settings.timeout}s` - }) - } - if (settings.debounce_delay_s != undefined && settings.debounce_delay_s > 0) { - badges.push({ - key: 'debounce', - label: 'Debounce', - icon: Hourglass, - detail: `Debounced by ${settings.debounce_delay_s}s` + key: 'priority', + short: 'Priority', + setting: 'priority', + label: 'High priority', + icon: ChevronsUp, + detail: `Priority ${settings.priority}` }) } if (settings.restart_unless_cancelled) { badges.push({ key: 'perpetual', + short: 'Perpetual', + setting: 'perpetual', label: 'Perpetual', icon: Repeat, detail: 'Restarts unless cancelled' }) } - if (settings.dedicated_worker) { - badges.push({ - key: 'dedicated', - label: 'Dedicated', - icon: Cpu, - detail: 'Runs on dedicated workers' - }) - } if (settings.delete_after_secs != undefined) { badges.push({ key: 'delete_after_use', + short: 'Auto-delete', + setting: 'lifetime', label: 'Delete after use', icon: Trash2, detail: @@ -117,21 +192,78 @@ export function getActiveScriptSettingsBadges( : `Deleted ${settings.delete_after_secs}s after completion` }) } - if (settings.priority != undefined && settings.priority > 0) { - badges.push({ - key: 'priority', - label: 'High priority', - icon: ChevronsUp, - detail: `Priority ${settings.priority}` - }) - } if (settings.tag) { badges.push({ key: 'tag', + short: settings.tag, + setting: 'worker-group', label: settings.tag, icon: Tag, detail: `Worker tag: ${settings.tag}` }) } + if (settings.dedicated_worker) { + badges.push({ + key: 'dedicated', + short: 'Dedicated', + setting: 'dedicated-worker', + label: 'Dedicated', + icon: Cpu, + detail: 'Runs on dedicated workers' + }) + } + if (settings.same_worker) { + badges.push({ + key: 'same_worker', + short: 'Shared dir', + setting: 'same-worker', + label: 'Shared directory', + icon: FolderOpen, + detail: 'Steps run on the same worker and share ./shared' + }) + } + return badges +} + +// The active settings of a script's or flow's Permissions tab, in the order the tab lists them. +export function getActivePermissionBadges(settings: { + on_behalf_of_email?: string + job_token_scopes?: string[] | null + visible_to_runner_only?: boolean +}): ScriptSettingsBadge[] { + const badges: ScriptSettingsBadge[] = [] + if (settings.on_behalf_of_email) { + badges.push({ + key: 'on_behalf_of', + short: 'On behalf', + setting: 'on-behalf-of', + label: 'Run on behalf of', + icon: UserCog, + detail: settings.on_behalf_of_email + }) + } + if (settings.visible_to_runner_only) { + badges.push({ + key: 'invisible_runs', + short: 'Invisible', + setting: 'invisible-runs', + label: 'Invisible runs', + icon: EyeOff, + detail: 'Manual runs are visible only to whoever ran them' + }) + } + if (settings.job_token_scopes != null) { + badges.push({ + key: 'job_token', + short: 'Job token', + setting: 'job-token', + label: 'Restricted job token', + icon: KeyRound, + detail: + settings.job_token_scopes.length === 0 + ? 'No API access' + : settings.job_token_scopes.join(', ') + }) + } return badges } From f2f38ddd4fbbaa32583866b9da5e23824a0899e9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel <ruben@windmill.dev> Date: Tue, 27 Oct 2026 01:28:52 +0100 Subject: [PATCH 14/14] chore(main): release 1.850.0 (#12019) * chore(main): release 1.850.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 24 +++ backend/Cargo.lock | 204 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- chat-sdk/package-lock.json | 4 +- chat-sdk/package.json | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 22 files changed, 173 insertions(+), 149 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 9edb1a8e87..7636111a5f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.849.0" + ".": "1.850.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fccb9912..c9df7607e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [1.850.0](https://github.com/windmill-labs/windmill/compare/v1.849.0...v1.850.0) (2026-10-27) + + +### Features + +* add a home setting to hide empty folders ([#12011](https://github.com/windmill-labs/windmill/issues/12011)) ([6e40940](https://github.com/windmill-labs/windmill/commit/6e4094009901f0702dc8b102390466d120361156)) +* add ai review to fork deploys and deployment requests ([#12029](https://github.com/windmill-labs/windmill/issues/12029)) ([bac1e46](https://github.com/windmill-labs/windmill/commit/bac1e463e5759add52b34e0555d2530d671b2251)) +* build MCP OAuth urls from the api base url, narrowable per surface ([#12010](https://github.com/windmill-labs/windmill/issues/12010)) ([e0da560](https://github.com/windmill-labs/windmill/commit/e0da5605163ce5157e9b605667c5e5fc85894db9)) +* forward a pinned version through the hub project export proxy ([#12022](https://github.com/windmill-labs/windmill/issues/12022)) ([7b1dce5](https://github.com/windmill-labs/windmill/commit/7b1dce5af05660ffbe1febbe04976af139569a0b)) +* name the previewed item in an empty AI session's composer ([#12021](https://github.com/windmill-labs/windmill/issues/12021)) ([6939454](https://github.com/windmill-labs/windmill/commit/6939454edb55a8df39b4fc5460f77333554f48a5)) +* show the draft badge on Home's favorite and recent cards ([#12018](https://github.com/windmill-labs/windmill/issues/12018)) ([4a34dc6](https://github.com/windmill-labs/windmill/commit/4a34dc69f544460e7893b04dbfd002f1c67125ae)) +* tool approval and a question tool for ai agent steps ([#11826](https://github.com/windmill-labs/windmill/issues/11826)) ([a1bc9ec](https://github.com/windmill-labs/windmill/commit/a1bc9ec0c27ab615460b145e59d7e7b8fdea8406)) + + +### Bug Fixes + +* add job_id, step_id and flow_path to native ts step errors ([#12012](https://github.com/windmill-labs/windmill/issues/12012)) ([2b4dfcc](https://github.com/windmill-labs/windmill/commit/2b4dfccb36493365de4e358f4f8c0fb7d7009c4a)) +* build hub script urls and cache keys from the parsed id only ([#12028](https://github.com/windmill-labs/windmill/issues/12028)) ([e2e2e7d](https://github.com/windmill-labs/windmill/commit/e2e2e7d7c4924fe29a912c603554f8545184d704)) +* compute list row rights when drawn instead of reloading ([#12023](https://github.com/windmill-labs/windmill/issues/12023)) ([b813c1b](https://github.com/windmill-labs/windmill/commit/b813c1b35d20291e0bf2ec23add0f76de7788e27)) +* detect attached -c and --no-config in DENO_FLAGS ([#12027](https://github.com/windmill-labs/windmill/issues/12027)) ([a2b91fd](https://github.com/windmill-labs/windmill/commit/a2b91fd17e785f4c707eea092bc0b993419d7d7d)) +* serve new MCP OAuth sign-ins the session toolset on the tools server URLs ([#12024](https://github.com/windmill-labs/windmill/issues/12024)) ([ee4150a](https://github.com/windmill-labs/windmill/commit/ee4150a69cca745ce3a909cefa5b9c1627a5f992)) +* show the protection-rule bypass toggle inline ([#12034](https://github.com/windmill-labs/windmill/issues/12034)) ([df2e0cd](https://github.com/windmill-labs/windmill/commit/df2e0cdb9d56a9d5cb7f3d64ed6ff2a621d0cca2)) +* stop starred items showing a starred removal in the editor diff ([#12017](https://github.com/windmill-labs/windmill/issues/12017)) ([35be14e](https://github.com/windmill-labs/windmill/commit/35be14ea27e70025b46d33fb072ad3767211eb17)) + ## [1.849.0](https://github.com/windmill-labs/windmill/compare/v1.848.3...v1.849.0) (2026-10-25) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 3ae4630fe5..f449351d54 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2964,9 +2964,9 @@ dependencies = [ [[package]] name = "crypto-bigint" -version = "0.7.8" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d726facc8b27e604391b232b970cdc19b5468c69f08b466b9734c759216c7415" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" dependencies = [ "cpubits", "ctutils", @@ -3055,9 +3055,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" dependencies = [ "cmov", "subtle", @@ -4518,9 +4518,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" dependencies = [ "const-oid 0.10.2", "zeroize", @@ -5070,7 +5070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b805154de2e68f59874ec217ca36790dcffe500cd872c60fe509d28d0814a74d" dependencies = [ "ed448", - "elliptic-curve 0.14.2", + "elliptic-curve 0.14.3", "hash2curve", "rand_core 0.10.1", "serdect 0.4.3", @@ -5126,12 +5126,12 @@ dependencies = [ [[package]] name = "elliptic-curve" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4496221fcfc21015fd8c0e26fcfab00504832dc0604f52acdd7bad2a8dc81" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" dependencies = [ "base16ct 1.0.0", - "crypto-bigint 0.7.8", + "crypto-bigint 0.7.10", "crypto-common 0.2.2", "ff 0.14.0", "group 0.14.0", @@ -6241,7 +6241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1eaf40612d7d854743e7189228a6d528f0f6e8502cf6a0cb831d28a218b7f3f6" dependencies = [ "digest 0.11.3", - "elliptic-curve 0.14.2", + "elliptic-curve 0.14.3", ] [[package]] @@ -8423,7 +8423,7 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.2.1", + "openssl-probe 0.2.2", "openssl-sys", "schannel", "security-framework 3.7.0", @@ -9053,9 +9053,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-probe" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" [[package]] name = "openssl-src" @@ -9300,9 +9300,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.5.0" +version = "5.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" dependencies = [ "num-traits", ] @@ -9870,7 +9870,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.2", + "der 0.8.3", "spki 0.8.1", ] @@ -11102,9 +11102,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.7.0" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "967fa7f7935d7d6217d593df0e8e5c9307bcaf256974a5a9ee64476c97735389" +checksum = "8b0748e054a5f5598b3213cf72099ae6b2745ba4d996c59ccf455150a7dbb839" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -11510,7 +11510,7 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe 0.2.2", "rustls-pki-types", "schannel", "security-framework 3.7.0", @@ -11841,7 +11841,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct 1.0.0", "ctutils", - "der 0.8.2", + "der 0.8.3", "hybrid-array", "subtle", "zeroize", @@ -12544,7 +12544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ "base64ct", - "der 0.8.2", + "der 0.8.3", ] [[package]] @@ -13626,7 +13626,7 @@ source = "git+https://github.com/windmill-labs/tantivy?rev=ea3b818c7b93db0c2b5db dependencies = [ "fnv", "nom", - "ordered-float 5.5.0", + "ordered-float 5.5.1", "serde", "serde_json", ] @@ -15487,7 +15487,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-nats", @@ -15577,7 +15577,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.849.0" +version = "1.850.0" dependencies = [ "async-stream", "async-trait", @@ -15612,7 +15612,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -15625,7 +15625,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "argon2", @@ -15768,7 +15768,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -15792,7 +15792,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -15809,7 +15809,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "axum", @@ -15835,7 +15835,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.849.0" +version = "1.850.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15845,7 +15845,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -15862,7 +15862,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "base64 0.22.1", @@ -15884,7 +15884,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "axum", @@ -15907,7 +15907,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -15923,7 +15923,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -15945,7 +15945,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -15967,7 +15967,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -15981,7 +15981,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-nats", @@ -16017,7 +16017,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "axum", @@ -16043,7 +16043,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -16071,7 +16071,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "axum", @@ -16093,7 +16093,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -16113,7 +16113,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "axum", @@ -16155,7 +16155,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "axum", @@ -16185,7 +16185,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.849.0" +version = "1.850.0" dependencies = [ "lazy_static", "serde", @@ -16197,7 +16197,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.849.0" +version = "1.850.0" dependencies = [ "argon2", "axum", @@ -16221,7 +16221,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -16236,7 +16236,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.849.0" +version = "1.850.0" dependencies = [ "axum", "chrono", @@ -16275,7 +16275,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.849.0" +version = "1.850.0" dependencies = [ "chrono", "lazy_static", @@ -16290,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "axum", @@ -16309,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.849.0" +version = "1.850.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16427,7 +16427,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.849.0" +version = "1.850.0" dependencies = [ "chrono", "futures", @@ -16447,7 +16447,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.849.0" +version = "1.850.0" dependencies = [ "regex", "serde", @@ -16464,7 +16464,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16491,7 +16491,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "futures", @@ -16508,7 +16508,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.849.0" +version = "1.850.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16524,7 +16524,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -16546,7 +16546,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -16577,7 +16577,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "arc-swap", @@ -16602,7 +16602,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-stream", @@ -16638,7 +16638,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "futures", @@ -16657,7 +16657,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.849.0" +version = "1.850.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16666,7 +16666,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -16678,7 +16678,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "serde_json", @@ -16690,7 +16690,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "gosyn", @@ -16702,7 +16702,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -16714,7 +16714,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "serde_json", @@ -16726,7 +16726,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "nu-parser", @@ -16737,7 +16737,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16748,7 +16748,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16760,7 +16760,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16771,7 +16771,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-recursion", @@ -16794,7 +16794,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "serde_json", @@ -16806,7 +16806,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -16820,7 +16820,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16837,7 +16837,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -16850,7 +16850,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "serde", @@ -16862,7 +16862,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -16880,7 +16880,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16896,7 +16896,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16913,7 +16913,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -16927,7 +16927,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-recursion", @@ -16966,7 +16966,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "const_format", @@ -17004,7 +17004,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.849.0" +version = "1.850.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17015,7 +17015,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-recursion", @@ -17050,7 +17050,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17074,7 +17074,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17107,7 +17107,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17134,7 +17134,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17167,7 +17167,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17187,7 +17187,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17221,7 +17221,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17258,7 +17258,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17281,7 +17281,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17305,7 +17305,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-nats", @@ -17329,7 +17329,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17364,7 +17364,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17392,7 +17392,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-trait", @@ -17417,7 +17417,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17436,7 +17436,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-once-cell", @@ -17554,7 +17554,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.849.0" +version = "1.850.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b42cac10f0..d1cf452405 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.849.0" +version = "1.850.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.849.0" +version = "1.850.0" authors = ["Ruben Fiszel <ruben@windmill.dev>"] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index ba2d483531..236ece9050 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.849.0" +version = "1.850.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.849.0" +version = "1.850.0" dependencies = [ "proc-macro2", "quote", @@ -6442,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.849.0" +version = "1.850.0" dependencies = [ "convert_case", "serde", @@ -6451,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "serde_json", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "gosyn", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "serde_json", @@ -6511,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "nu-parser", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "async-recursion", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "serde_json", @@ -6591,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -6605,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "convert_case", @@ -6622,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "serde", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6698,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "lazy_static", @@ -6744,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.849.0" +version = "1.850.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 0d0697c3fd..d8778652b1 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.849.0" +version = "1.850.0" edition = "2021" authors = ["Ruben Fiszel <ruben@windmill.dev>"] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index fa372cc469..b27bdca80a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.849.0 + version: 1.850.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 41d2fe87c0..a32dea15a1 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.849.0"; +export const VERSION = "v1.850.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 61ed2382a8..cd6001ebd0 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/chat-sdk", - "version": "1.849.0", + "version": "1.850.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/chat-sdk", - "version": "1.849.0", + "version": "1.850.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 992d52b207..2393461e52 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.849.0", + "version": "1.850.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/core/constants.ts b/cli/src/core/constants.ts index bf083042d9..2c64194321 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.849.0"; +export const VERSION = "1.850.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 206f8bb476..b43e05a1d3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.849.0", + "version": "1.850.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.849.0", + "version": "1.850.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 86b73762fa..f8516cbc17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.849.0", + "version": "1.850.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/lsp/Pipfile b/lsp/Pipfile index db712975b4..09216c1d90 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.849.0" +wmill = ">=1.850.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 1d3ee7db95..23cd13351d 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.849.0 + version: 1.850.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 786d50f9fa..84193c136c 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.849.0' + ModuleVersion = '1.850.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index b365d607b8..c27df35a3a 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.849.0" +version = "1.850.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/typescript-client/jsr.json b/typescript-client/jsr.json index 7606c7fd06..32f7fa375e 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.849.0", + "version": "1.850.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 70dec2bd09..a1f7b803a9 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.849.0", + "version": "1.850.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 d8b82c7f85..450ff96ba6 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.849.0 +1.850.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 211d9f0455..5bf9e0398d 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.849.0", + "version": "1.850.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.849.0", + "version": "1.850.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 abfe2607d0..2de2832350 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.849.0", + "version": "1.850.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts",