diff --git a/backend/windmill-api/src/agent_runs.rs b/backend/windmill-api/src/agent_runs.rs index 3bf65c9808..74f910a5b2 100644 --- a/backend/windmill-api/src/agent_runs.rs +++ b/backend/windmill-api/src/agent_runs.rs @@ -34,7 +34,7 @@ use crate::jobs::{ /// The id the agent's step carries in the run, as the editor's chat names it. A token scoped to /// `jobs:run:agents:` reads its runs back by it (`require_job_within_read_scope`). -const AGENT_NODE_ID: &str = "__wm_agent_root"; +pub(crate) const AGENT_NODE_ID: &str = "__wm_agent_root"; /// A turn of the agent's chat when `memory_id` names a conversation, a single run otherwise. /// Returns the job id. diff --git a/backend/windmill-api/src/mcp/session/gen/instructions.json b/backend/windmill-api/src/mcp/session/gen/instructions.json index 4f366ea992..699956e83d 100644 --- a/backend/windmill-api/src/mcp/session/gen/instructions.json +++ b/backend/windmill-api/src/mcp/session/gen/instructions.json @@ -112,6 +112,34 @@ "bunnative": "app", "dbt": "app" }, + "agent": { + "": "agent", + "python3": "agent", + "deno": "agent", + "go": "agent", + "bash": "agent", + "powershell": "agent", + "postgresql": "agent", + "mysql": "agent", + "bigquery": "agent", + "snowflake": "agent", + "mssql": "agent", + "oracledb": "agent", + "graphql": "agent", + "nativets": "agent", + "bun": "agent", + "php": "agent", + "rust": "agent", + "ansible": "agent", + "csharp": "agent", + "nu": "agent", + "java": "agent", + "ruby": "agent", + "rlang": "agent", + "duckdb": "agent", + "bunnative": "agent", + "dbt": "agent" + }, "datatable": { "": "datatable", "python3": "datatable.python3", @@ -224,10 +252,11 @@ "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, `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", + "flow": "# Global draft flow instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Never invent a folder.\n- `write_flow` mirrors flow mode's `set_flow_json`: pass `path`, optional `summary`, optional `description`, required `modules`, and optional `schema`, `preprocessor_module`, `failure_module`, `groups`, and `notes`. `summary` and `description` are top-level flow metadata (not part of the compact value `patch_flow_json` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. When overwriting an existing flow, top-level flow settings (see below) are preserved from the current flow — use `patch_flow_json` to change them.\n- `read_workspace_item` returns a compact flow `value` object with `modules`, `schema`, `preprocessor_module`, `failure_module`, `groups`, `notes`, and any top-level flow settings that are set.\n- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with `patch_flow_json`: same_worker, preserve_step_tags, concurrent_limit, concurrency_key, concurrency_time_window_s, debounce_delay_s, debounce_key, debounce_args_to_accumulate, max_total_debouncing_time, max_total_debounces_amount, skip_expr, cache_ttl, cache_ignore_s3_path, delete_after_secs, flow_env, priority, early_return, chat_input_enabled. For example, `chat_input_enabled: true` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow.\n- `modules` contains normal sequential modules. Use top-level `preprocessor_module` and `failure_module` for special modules; do not put `preprocessor` or `failure` in `modules`.\n- Every module needs a stable unique `id` and a useful `summary` when the schema supports it.\n- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.\n- When writing rawscript module code, call `get_instructions` with `subject: \"script\"` and the rawscript language first.\n\n## Groups and notes\n\n- Add `groups` to any non-trivial flow without being asked; \"Organizing Flows: Groups and Notes\" in the reference below gives the fields and the rules the editor enforces. Pass `null` to `write_flow` to clear them, and the same for `notes`.\n- With `patch_flow_json`, edit `groups` and `notes` the same way as any other field — they appear as top-level keys in the compact flow value.\n\n## Compact view: how rawscript bodies surface in tool I/O\n\n- `read_workspace_item` and `patch_flow_json` operate on a **compact view** of the flow: every rawscript module's `value.content` is replaced with the placeholder `\"inline_script.\"` so inline script bodies don't bloat tool I/O. Schema, groups, notes, preprocessor_module and failure_module are all shown in this view.\n- Inline rawscript content is **not** part of the JSON `patch_flow_json` sees. Edits to inline bodies happen via dedicated tools:\n - `read_flow_module_code(path, module_id)` — returns the raw inline script content for one module.\n - `set_flow_module_code(path, module_id, code)` — overwrites that module's inline script content; saves to the draft.\n- Use `patch_flow_json` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups/notes. Use `set_flow_module_code` for changes inside a specific rawscript body.\n- When **adding a new rawscript module** with `patch_flow_json`, set its `content` to the placeholder `\"inline_script.\"` (matching the compact view) — never real code — then **immediately fill the body** with `set_flow_module_code(path, module_id, code)`. The module is saved with an empty body until you do; the tool result lists the module ids still awaiting code. A placeholder that names anything other than the module's own id or an existing rawscript module is rejected.\n- `write_flow` is for full overwrites / create-from-scratch. Its `modules`, `preprocessor_module`, and `failure_module` arguments are **non-compact** flow modules: inline each rawscript body directly in `content` by default. But if a body is long or quote/backslash-heavy enough that escaping it into the JSON string is error-prone — or if a `write_flow` call comes back with a JSON parse error — create that module with **empty content** (`\"content\": \"\"`) and fill it with `set_flow_module_code(path, module_id, code)`, whose `code` is a plain argument with no nested escaping. `write_flow` reports which modules still have empty bodies so you know what to fill.\n - When overwriting an **existing** flow, set `\"content\": \"inline_script.\"` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected.\n\n\n# Windmill flow authoring reference\n\n# Windmill Flow Building Guide\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Hard Structural Rules\n\nThese are strict Windmill schema rules. Follow them exactly.\n\n- `value.modules` is only for normal sequential steps\n- `value.preprocessor_module` and `value.failure_module` are special top-level fields inside `value`, not entries in `value.modules`\n- If a flow needs a preprocessor, create `value.preprocessor_module` with `id: preprocessor`\n- If a flow needs a failure handler, create `value.failure_module` with `id: failure`\n- Do NOT create regular modules inside `value.modules` named `preprocessor` or `failure`\n- `preprocessor_module` and `failure_module` only support `script` or `rawscript`\n- `preprocessor_module` runs before normal modules and cannot reference `results.*`\n- `failure_module` can use the `error` object with `error.message`, `error.step_id`, `error.name`, and `error.stack`\n- A flow whose `failure_module` runs still ends as failed, unless the handler returns an object with `recover: true`: the flow then ends as a success. This also holds when the failing step is inside a loop, a branch or a subflow. `recover` never changes which steps run; whether a loop or branch carries on past a failure is still decided by its own settings (`skip_failures`, `continue_on_error`)\n\nCorrect shape:\n\n```yaml\nvalue:\n preprocessor_module:\n id: preprocessor\n value:\n type: rawscript\n ...\n failure_module:\n id: failure\n value:\n type: rawscript\n ...\n modules:\n - id: process_event\n value:\n type: rawscript\n ...\n```\n\nIncorrect shape:\n\n```yaml\nvalue:\n modules:\n - id: preprocessor\n ...\n - id: process_event\n ...\n - id: failure\n ...\n```\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## AI Agent Modules\n\nAn `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped\nobject with an extra `value.tool_type`: `flowmodule` for a script or inline script tool (or a nested\nagent or AI decision), `mcp` for an MCP server tool, `websearch` for web search, `question` for a\nbuilt-in `ask_question` tool (the agent stops, without holding a worker, until someone answers from\nthe run page or the chat; optional `value.timeout` in seconds, default 1800). Set\n`requires_approval: true` on a `flowmodule` or `mcp` tool to have a person approve or reject each of\nits calls before it runs.\n\n```json\n{\n \"id\": \"support_agent\",\n \"summary\": \"AI agent for customer support\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"openai\", \"resource\": \"$res:f/ai_providers/openai\", \"model\": \"gpt-4o\" }\n },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.query\" },\n \"system_prompt\": { \"type\": \"static\", \"value\": \"You are a helpful assistant.\" }\n },\n \"tools\": [\n {\n \"id\": \"search_docs\",\n \"summary\": \"search_documentation\",\n \"description\": \"Search the product documentation. Use it whenever the user asks how a feature works.\",\n \"value\": {\n \"tool_type\": \"flowmodule\",\n \"type\": \"rawscript\",\n \"language\": \"bun\",\n \"content\": \"export async function main(query: string) { return ['doc1', 'doc2']; }\",\n \"input_transforms\": { \"query\": { \"type\": \"ai\" } }\n }\n }\n ]\n }\n}\n```\n\n- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script\n that runs the flow\n- Each argument of a tool is either the model's or fixed. `{ \"type\": \"ai\" }` (or no value) lets the\n model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix\n what the model must not choose, such as a resource, a table name or a limit\n- `provider` is an object, not a bare resource string: `{ \"kind\": ,\n \"resource\": \"$res:\", \"model\": }`. Required unless the module links to a saved\n agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its\n fields to flow inputs instead — see below\n\n### Chat-Mode Flows\n\nA flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer\nsends one message per turn and renders the conversation. It needs a required `user_message` string\ninput, read by the agent. Any other flow input the composer does not edit itself is asked for\nunder Configure inputs.\n\n**A static `provider` gives a chat that cannot change its model.** Feed it from flow inputs\ninstead, either way round: one input carrying the whole object (`\"expr\": \"flow_input.model_config\"`)\nmakes every field editable, or wire it field by field to fix some and expose others. A field the\nchat can write becomes a control in the composer — a provider picker, a model list, a thinking\ncontrol — and a field left static is fixed, with no control drawn for it. `kind` is the one\nexception: the composer writes it only together with `resource`, since a provider is picked as a\npair, so a `kind` input wired on its own stays askable under Configure inputs and nothing the run\nneeds becomes unreachable.\n\n```json\n{\n \"id\": \"chat_agent\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"javascript\",\n \"expr\": \"({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })\"\n },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.user_message\" },\n \"user_attachments\": { \"type\": \"javascript\", \"expr\": \"flow_input.files\" },\n \"memory\": { \"type\": \"static\", \"value\": { \"kind\": \"compaction\" } },\n \"streaming\": { \"type\": \"static\", \"value\": true },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" }\n },\n \"tools\": []\n }\n}\n```\n\n- Wiring field by field means one object literal whose values are literals or bare `flow_input.x`\n references. A spread, a call or a computed key leaves the composer unable to tell which input\n feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object\n is read instead as that one input carrying every field\n- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same\n two controls through its `model` and `reasoning_effort` inputs, which replace what the agent\n saved: `\"model\": { \"type\": \"javascript\", \"expr\": \"flow_input.model\" }`. An empty model keeps the\n saved one, an empty effort sends none, and the provider resource always stays the agent's\n- `memory` is what lets the agent see earlier turns; without it every message starts from nothing\n- `streaming` on makes the answer and its thinking appear token by token instead of all at once\n- `user_attachments` points at a flow input typed as an array of s3 objects\n (`{ \"type\": \"array\", \"items\": { \"type\": \"object\", \"resourceType\": \"s3object\" } }`), so files\n sent with a message reach the agent\n- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the\n conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat\n supplies it itself; a run driven any other way has to pass it or the server refuses the job\n\n### Tool Naming Rules\n\nThese rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` or `question`\ntool's `summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP\nserver's own tool names, so none is name-checked at all — leave those summaries as they are.\n\n- A flowmodule tool's `summary` is the **name the agent calls it by**, not a human label. Put the\n human-readable explanation in `description`\n- `summary` must match `^[a-zA-Z0-9_]+$`: letters, numbers and underscores only. No spaces, dashes,\n dots or accents — `search_documentation`, never `Search documentation`\n- Always set `summary`. It must be unique among that agent's tools, and must not be one of the\n reserved ids (`do`, `bg`, `ctx`, `state`, `if`, `else`, `for`, `delete`, `while`, `new`, `in`,\n `failure`, `preprocessor`, `as`, `Input`, `Result`, `Trigger`)\n- A tool name outside that character set fails any run that offers the tool to the agent, with `Invalid tool name`.\n The flow write tools refuse such a name.\n- Tool `id` follows the same rules as any module ID — unique across the flow, underscores not spaces\n- `description` is optional free text telling the agent when and how to call the tool. Set it\n whenever the name alone does not make that obvious; it overrides the description derived from the\n underlying script\n\n## AI Decision Modules\n\nAn `aidecision` module asks a decision model (TypeSafe's Jev, Cloudflare's Clef or OpenAI's Decisions API) typed questions about a `state` and\nanswers each with calibrated probabilities instead of text. Prefer it over an `aiagent` when the step\nis a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it\nis faster, cheaper, and its answers have a fixed shape.\n\n```json\n{\n \"id\": \"triage\",\n \"summary\": \"Classify the ticket\",\n \"value\": {\n \"type\": \"aidecision\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"typesafe\", \"resource\": \"$res:f/ai/typesafe\", \"model\": \"jev-latest\" }\n },\n \"state\": { \"type\": \"javascript\", \"expr\": \"flow_input.message\" },\n \"questions\": {\n \"type\": \"static\",\n \"value\": {\n \"intent\": {\n \"type\": \"choice\",\n \"instructions\": \"What does the customer want?\",\n \"criteria\": { \"refund\": \"Money back\", \"bug\": \"Something is broken\", \"other\": \"Anything else\" }\n },\n \"urgency\": {\n \"type\": \"score\",\n \"instructions\": \"How urgent is the ticket?\",\n \"criteria\": [\"Can wait\", \"This week\", \"Today\", \"Right now\"]\n },\n \"angry\": { \"type\": \"noul\", \"instructions\": \"Is the customer angry?\" }\n }\n }\n }\n }\n}\n```\n\n- `provider.kind` is `typesafe` (`model` `jev-latest` unless a version is pinned), `cloudflare`\n (`model` `clef`, or `clef-flash` for faster answers) or `openai` (`model` `gpt-6-luna`, never a\n chat model); the resource is of that same type\n- `state` is the content to evaluate: usually a text, such as the message to classify. To combine\n several values, pass an object with descriptive keys holding only what the questions need\n (`({ message: flow_input.message, plan: results.get_account.plan })`); an array of strings also works\n- `questions` maps each question name to `{ type, instructions, criteria }`:\n - `choice`: `criteria` maps each option to its description (up to 255 options). The answer has\n `choice`, `probabilities` and `confidence`\n - `score`: `criteria` is an ordered array of 2 to 10 level descriptions. The answer has `score`,\n `legend`, `probabilities` and `confidence`\n - `noul`: yes/no, `criteria` optionally `{ \"true\": ..., \"false\": ... }` descriptions. The answer has\n `noul`, the probability of yes from 0 to 1\n- The result is `{ output, model, usage }` with `output` keyed by question name, so a later step\n reads `results.triage.output.intent.choice`, `results.triage.output.urgency.score` or\n `results.triage.output.angry.noul > 0.7`\n- On `openai` the same `questions` are translated for its Decisions API and the answers read the\n same way (`choice`, `score`, `noul`), except that `probabilities` is a list of\n `{ value, probability }` (plus `label` on a score) and a score has no `legend`. A question OpenAI\n refuses to answer fails the step. A `state` that is\n an array of user messages (`[{ role: \"user\", content: [{ type: \"input_text\", text }, { type: \"input_image\", image_url: \"data:image/png;base64,...\" }] }]`)\n is sent as is, which is how an image is evaluated; any other object or array is sent as JSON text\n\n### Branching on the Answers\n\nTo route on the answers, put a `branchone` right after the decision, one branch per option, with\neach `expr` reading the decision by id:\n\n```json\n{\n \"id\": \"route\",\n \"value\": {\n \"type\": \"branchone\",\n \"branches\": [\n { \"summary\": \"refund\", \"expr\": \"results.triage.output.intent.choice === 'refund'\", \"modules\": [...] },\n { \"summary\": \"bug\", \"expr\": \"results.triage.output.intent.choice === 'bug'\", \"modules\": [...] }\n ],\n \"default\": [...]\n }\n}\n```\n\nFor a yes/no question, one branch with `\"expr\": \"results.triage.output.angry.noul >= 0.5\"` (tune the\nthreshold as needed) and the `default` as the \"no\" path.\n\n### As an Agent Tool\n\nAn `aidecision` can be a `flowmodule` tool of an `aiagent` (`\"tool_type\": \"flowmodule\", \"type\":\n\"aidecision\"`): set `state` to `{ \"type\": \"ai\" }` so the calling model supplies it, and only `output`\ngoes back to the model. The Tool Naming Rules apply to its `summary`. Only a flow's own agent step\ncan call one, not an agent used as a tool.\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n- AI agent flowmodule tool names with spaces - `summary` is the tool name and only accepts letters, numbers and underscores\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step only when that step result is in scope\n- `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope\n- `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`)\n- `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`)\n- After a suspend step (approval), the next step's javascript expressions also get:\n - `resume` - the last resume payload (the resume form values); with `continue_on_disapprove_timeout`, a disapproval or timeout yields `{ error: { name: \"SuspendedDisapproved\" | \"SuspendedTimedOut\", message } }` instead\n - `resumes` - every resume payload, in arrival order\n - `approvers` - a plain array of strings, one per resume: for a resume through the `resume`/`cancel` URL from `getResumeUrls(approver)`, the `approver` passed there if any; otherwise (including the `approvalPage` link) the resuming user's username, else `\"anonymous\"`, e.g. `approvers[0]`. It is NOT the `{ resume_id, approver }` objects stored in the flow status, so `approvers[0].approver` is always undefined\n\n## Loop Structure Rules\n\n- A `forloopflow` runs its `modules` once per element of `iterator`, a javascript expression returning an array (e.g. `results.get_items`); `parallel: true` runs the iterations concurrently, and `skip_failures: true` carries on past a failed iteration\n- For `whileloopflow`, break the loop with a module-level `stop_after_if`: on the loop module itself, or on an inner step (required when that step carries state via its own `results` — see below)\n- `stop_after_if` is always a sibling of `id` and `value` on a flow module — never a direct key of the loop's `value` object\n- `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition\n- `stop_after_if` is evaluated after each iteration: on the loop module, `result` is that iteration's result (what its last step returned); on an inner step, it is that step's result\n- `flow_input.iter.value` in a `whileloopflow` is just the iteration index (same number as `flow_input.iter.index`) — it never carries state, so `flow_input.iter.value.` is always undefined and a loop whose stop condition depends on it never terminates\n- To carry state across iterations, a step reads its own previous-iteration result via `results.` with a first-iteration fallback (e.g. `results.b ?? flow_input.start`) — but then the loop's `stop_after_if` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where `results.` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own `stop_after_if`, retry or similar, resolve `results` across iterations regardless of stop placement)\n- For state that is just a counter, derive it from the index instead (e.g. `flow_input.iter.index + 1`) — that works in every configuration, including with `stop_after_if` on the loop module\n- If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array\n\nCorrect `whileloopflow` shape:\n\n```yaml\n- id: loop_until_done\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n value:\n type: rawscript\n input_transforms:\n count:\n type: javascript\n expr: flow_input.iter.index + 1\n- id: return_final_state\n value:\n type: rawscript\n input_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done[results.loop_until_done.length - 1]\n```\n\nCorrect `whileloopflow` shape carrying state via `results` (stop condition on the inner step):\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: rawscript\n input_transforms:\n state:\n type: javascript\n expr: results.advance_state ?? flow_input.initial_state\n```\n\nIncorrect `whileloopflow` patterns:\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n stop_after_if:\n expr: result.done === true\n```\n\n```yaml\ninput_transforms:\n state:\n type: javascript\n # iter.value is a number (the iteration index); there is no previous-iteration state\n expr: flow_input.iter.value.count\n```\n\n```yaml\ninput_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done\n```\n\n## Approval / Suspend Structure\n\nAn approval step is a normal **script** step (`type: rawscript` or `type: script`) that is turned into an approval by adding a module-level `suspend`. Its script calls `wmill.getResumeUrls(approver)` to generate the secret resume/cancel URLs and returns them so they can be sent to the approver(s) (Slack, email, etc.) or approved from the run page.\n\n- `suspend` belongs on the flow module object itself, as a sibling of `id` and `value`\n- Never put `suspend` inside `value`\n- Do NOT use `type: identity` for an approval step. An identity step suspends but never produces the resume URLs, so approvers have no link to act on — it is not a functional approval.\n\nCorrect shape:\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n resume_form:\n schema:\n type: object\n properties:\n comment:\n type: string\n required: [comment]\n value:\n type: rawscript\n language: bun\n input_transforms:\n approver:\n type: static\n value: ''\n content: |\n import * as wmill from \"windmill-client\"\n\n export async function main(approver?: string) {\n const urls = await wmill.getResumeUrls(approver)\n // send urls.resume / urls.cancel to the approver(s), e.g. via Slack or email\n return urls\n }\n```\n\nIncorrect shape (suspend misplaced inside `value`):\n\n```yaml\n- id: request_approval\n value:\n type: rawscript\n suspend:\n required_events: 1\n```\n\nIncorrect shape (identity has no resume URLs — not a real approval):\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n value:\n type: identity\n```\n\n## Branch Result Scope Rules\n\n- A `branchone` runs the first of its `branches` whose `expr` is true, in order, and its `default` modules when none is; a `branchall` runs every branch (concurrently with `parallel: true`)\n- Inside a branch, you may reference earlier outer steps and earlier steps in the same branch\n- Outside a `branchone`, do NOT reference ids of steps that only exist inside its branches or default branch. Use `results.` instead\n- Outside a `branchall`, do NOT reference ids of steps inside its branches. Use `results.` instead\n- If downstream steps need a stable shape after a branch, make each branch return the same fields\n- When needed, add a normalization step immediately after the branch and consume `results.` there\n\nCorrect after `branchone`:\n\n```yaml\n- id: route_order\n value:\n type: branchone\n ...\n- id: send_confirmation\n value:\n input_transforms:\n routed:\n type: javascript\n expr: results.route_order\n```\n\nIncorrect after `branchone`:\n\n```yaml\nexpr: results.create_shipment\nexpr: results.create_backorder\n```\n\nCorrect after `branchall`:\n\n```yaml\n- id: enrich_parallel\n value:\n type: branchall\n parallel: true\n ...\n- id: combine_data\n value:\n input_transforms:\n enrichments:\n type: javascript\n expr: results.enrich_parallel\n```\n\n## Input Transforms\n\nEvery rawscript module needs `input_transforms` to map function parameters to values:\n\nStatic transform (fixed value):\n{\"param_name\": {\"type\": \"static\", \"value\": \"fixed_string\"}}\n\nJavaScript transform (dynamic expression):\n{\"param_name\": {\"type\": \"javascript\", \"expr\": \"results.previous_step.data\"}}\n\n## Resource References\n\n- For flow inputs: Use type `\"object\"` with format `\"resource-{type}\"` (e.g., `\"resource-postgresql\"`)\n- For step inputs: Use static value `\"$res:path/to/resource\"`\n\n## Reusing Existing Scripts and Flows\n\nUnless the user asked for new code, look for a workspace script or flow that already does a step's job before writing it, and reuse it by path instead of copying its logic into a rawscript:\n\n- a workspace script: `type: script` with `path` (e.g. `f/folder/send_email`)\n- a workspace flow, run as a subflow: `type: flow` with `path`\n- a Hub script: `type: script` with a `hub///` path\n\nThe step's `input_transforms` must cover the reused item's inputs, so read its input schema first.\n\n## Additional Prompt for AI\n\nA flow's input schema may carry a top-level `prompt_for_ai` string, as a script's can: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that flow, and keep it when you rewrite the schema.\n\n## Organizing Flows: Groups and Notes\n\nGroups and notes shape how a flow reads in the editor; neither changes what it does.\n\n**Segment every non-trivial flow into groups without waiting to be asked.** Whenever a flow has more than a couple of steps, or consecutive steps form a stage (\"fetch\", \"transform\", \"notify\"), put them in a group, and aim for every meaningful step to belong to one. Use notes sparingly, for flow-wide information that belongs to no span of steps: the flow's purpose, key assumptions, warnings, TODOs. One note is usually enough; never label a run of steps with a note, which is what a group is for.\n\n`value.groups` lists the groups, each spanning the steps from `start_id` to `end_id`:\n\n- `start_id`, `end_id` (required): ids of the group's first and last step; the same id for both makes a one-step group\n- `summary`: the group's title\n- `note`: markdown shown under the title\n- `color`: one of `yellow`, `blue`, `green`, `purple`, `pink`, `orange`, `red`, `cyan`, `lime`, `gray`, never a hex code or CSS color; leave it out and the editor picks one\n- `autocollapse`: `true` shows the group collapsed by default\n\nThe editor refuses to draw a flow whose groups break any of these rules:\n\n- `start_id` and `end_id` are steps of the same list: both top-level, or both in the same loop body or branch. A group can hold a loop or branch step whole, but cannot start outside one and end inside it\n- `start_id` does not come after `end_id` in that list\n- groups nest (one entirely inside another) but never partly overlap, and no two groups share both `start_id` and `end_id`\n- groups hold ordinary steps only: never `preprocessor`, `failure`, `Input`, `Result`, `Trigger`, or an AI agent's tools\n\n`value.notes` lists sticky notes, each with a unique `id`, markdown `text`, a `color` from the same list, and `type: free`. The `group` note type is deprecated; use `value.groups` instead.\nLeave a note's `position` and `size` out and they are filled in for you.\n\n## Final Structural Self-Check\n\nBefore finalizing a flow, verify:\n\n- any preprocessor is in `value.preprocessor_module`\n- any failure handler is in `value.failure_module`\n- any approval step has module-level `suspend`\n- no downstream step references inner branch step ids from outside the branch\n- every AI agent flowmodule tool has a unique `summary` made only of letters, numbers and underscores\n- every group starts and ends on steps of the same list, start before end, nesting without partial overlap\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\nTo accept an S3 object as flow input:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"object\",\n \"format\": \"resource-s3_object\",\n \"description\": \"File to process\"\n }\n }\n}\n```\n\n## Using Resources in Flows\n\nOn Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.\n\n### As Flow Input\n\nIn the flow schema, set the property type to `\"object\"` with format `\"resource-{type}\"`:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"database\": {\n \"type\": \"object\",\n \"format\": \"resource-postgresql\",\n \"description\": \"Database connection\"\n }\n }\n}\n```\n\n### When No Resource Type Fits\n\nA connection still comes in as one resource input, not as separate string inputs (a URL, a token) or a variable read inside a step: type the input `resource-c_` with a custom resource type holding the service's connection fields, its secret in a variable the resource references. The `c_` prefix of custom types keeps one apart from a hub type synced later under the same name.\n\n- If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services.\n- Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it.\n- A plain variable is fine when the user asks for it.\n\n### As Step Input (Static Reference)\n\nReference a specific resource using `$res:` prefix:\n\n```json\n{\n \"database\": {\n \"type\": \"static\",\n \"value\": \"$res:f/folder/my_database\"\n }\n}\n```\n\n\n## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names.\"},\"on_behalf_of\":{\"type\":\"string\",\"description\":\"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"preserve_step_tags\":{\"type\":\"boolean\",\"description\":\"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag.\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the flow job's args, result and logs after this many seconds following job completion\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead.\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff: the n-th retry waits multiplier \\u00d7 seconds^n\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled.\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead.\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one.\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"},\"error_include_result\":{\"type\":\"boolean\",\"description\":\"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the step's args, result and logs after this many seconds following job completion\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"},\"skin\":{\"type\":\"string\",\"enum\":[\"detailed\",\"minimal\"],\"description\":\"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"job_token_scopes\":{\"type\":\"array\",\"nullable\":true,\"items\":{\"type\":\"string\"},\"description\":\"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"azure_foundry\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\",\"typesafe\",\"cloudflare\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"},\"reasoning_effort\":{\"type\":\"string\",\"description\":\"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryWindow\":{\"type\":\"object\",\"description\":\"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n`memory_id`). Without a memory id the agent runs without memory.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"window\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Number of most recent messages to load and store. 0 turns memory off.\"}},\"required\":[\"kind\",\"context_length\"]},\"MemoryAuto\":{\"type\":\"object\",\"deprecated\":true,\"description\":\"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryCompaction\":{\"type\":\"object\",\"description\":\"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"compaction\"]},\"context_window\":{\"type\":\"integer\",\"description\":\"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"deprecated\":true,\"description\":\"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryWindow\"},{\"$ref\":\"#/components/schemas/MemoryCompaction\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"window\":\"#/components/schemas/MemoryWindow\",\"compaction\":\"#/components/schemas/MemoryCompaction\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"},{\"$ref\":\"#/components/schemas/AiDecision\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\",\"aidecision\":\"#/components/schemas/AiDecision\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"bunnative\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"rlang\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\",\"dbt\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp, websearch or question tool it is a plain label. Put the human-readable explanation in 'description'.\"},\"description\":{\"type\":\"string\",\"description\":\"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.\"},\"job_token_scopes\":{\"type\":\"array\",\"nullable\":true,\"items\":{\"type\":\"string\"},\"description\":\"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it.\"},\"requires_approval\":{\"type\":\"boolean\",\"description\":\"Flowmodule and mcp tools only. Each call waits for a person to approve or reject it from the flow's run page or chat, without the agent holding a worker. Calls in the same turn that do not require approval run right away. A rejected call, or one nobody answers within 30 minutes, is reported to the AI as not run.\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"},{\"$ref\":\"#/components/schemas/QuestionToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\",\"question\":\"#/components/schemas/QuestionToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"QuestionToolValue\":{\"type\":\"object\",\"description\":\"Built-in tool the AI calls as ask_question: the agent stops, releasing its worker, until someone answers from the flow's run page or chat or the question times out, then resumes with the answer as the tool result.\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"question\"]},\"timeout\":{\"type\":\"integer\",\"description\":\"Seconds to wait for an answer before the question is reported to the AI as timed out. Defaults to 1800.\"}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\\nthe prompt; image output always needs it.\\n\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"memory_id\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the `memory_id` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\\nolder `auto` or `manual` memory reads neither history input.\\n\"},\"previous_messages\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while `memory` is off or absent: managed memory\\nignores it, and an older `auto` or `manual` memory reads neither history input.\\n\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"enabled_tools\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of strings naming which of the tools configured in `tools` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"},\"max_iterations\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n\"},\"model\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Only on a step linked to a saved agent (see `agent`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n`flow_input.model` to choose the model per run.\\n\"},\"reasoning_effort\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside `provider`.\\n\"}}},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)\"},\"omit_output_from_conversation\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.\"},\"agent\":{\"type\":\"string\",\"description\":\"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\\n\"},\"tool_inputs\":{\"type\":\"object\",\"description\":\"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n\",\"additionalProperties\":{\"type\":\"object\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}}},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"type\",\"input_transforms\"]},\"AiDecision\":{\"type\":\"object\",\"description\":\"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { : answer },\\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults..output..choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the decision mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"state\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n\"},\"questions\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n\"}},\"required\":[\"provider\",\"state\",\"questions\"]},\"type\":{\"type\":\"string\",\"enum\":[\"aidecision\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)\"}},\"required\":[\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"approval\"]},\"call_id\":{\"type\":\"string\"},\"calls\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"function_name\":{\"type\":\"string\"},\"arguments\":{}},\"required\":[\"function_name\",\"arguments\"]}},\"expires_at\":{\"type\":\"string\",\"format\":\"date-time\"}},\"required\":[\"type\",\"call_id\",\"calls\",\"expires_at\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"question\"]},\"call_id\":{\"type\":\"string\"},\"question\":{\"type\":\"string\"},\"choices\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"answers the model proposes; a free-text answer is always possible\"},\"multi_select\":{\"type\":\"boolean\",\"description\":\"whether several choices can be picked together\"},\"expires_at\":{\"type\":\"string\",\"format\":\"date-time\"}},\"required\":[\"type\",\"call_id\",\"question\",\"expires_at\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}", + "resource": "# Global draft resource & variable instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- A resource draft is a workspace item: `{ type: 'resource', path, summary?, value, isDraft }`. `value` is a CreateResource body: `{ path, value, description?, resource_type, labels? }` where the inner `value` is the resource type's data shape.\n- Reading a variable returns `{ type: 'variable', path, summary?, isSecret, isDraft }` — never its value, secret or not. `isSecret` tells you whether the value is encrypted.\n- `write_variable` takes `{ path, value?, is_secret?, description?, is_oauth?, expires_at?, value_valid_until?, labels? }`. Creating a variable needs `value` and `is_secret`; editing one needs only the fields you are changing. Omitting `value` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any `value` you did not get from the user destroys it.\n- For secret fields in a resource value, create the variable with `write_variable` and `is_secret: true`, and deploy it before the resource (see \"Secrets\" in the reference below).\n- Reference formats inside resource values: `$var:g/all/name` (global), `$var:u/user/name` (user), `$var:f/folder/name` (folder). Reference another resource with `$res:path/to/resource`. The same strings are also how a resource or variable is passed as a run argument (see the run-argument rule in the resource reference below); what they are never valid as is a variable's own value.\n- Use `search_resource_types` to discover valid `resource_type` names and their JSON Schemas. Match the resource value to that schema.\n- For an agent (an `ai_agent` resource), call `get_instructions` with `subject: \"agent\"` instead.\n- For OAuth resources, the `is_oauth: true` flag is managed by Windmill's OAuth flow; global mode generally creates manual resources, not OAuth ones.\n\n# Windmill resource & variable reference\n\n# Windmill Resources\n\nResources store credentials and configuration for external services.\n\n## Resource Structure\n\n```json\n{\n \"value\": {\n \"host\": \"db.example.com\",\n \"port\": 5432,\n \"user\": \"admin\",\n \"password\": \"$var:g/all/db_password\",\n \"dbname\": \"production\"\n },\n \"description\": \"Production PostgreSQL database\",\n \"resource_type\": \"postgresql\"\n}\n```\n\n## Required Fields\n\n- `value` - Object containing the resource configuration\n- `resource_type` - Name of the resource type (e.g., \"postgresql\", \"slack\")\n\n## Agents\n\nAn agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the\nagent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by\nlinking its path.\n\n## Variable References\n\nReference variables in resource values:\n\n```json\n{\n \"value\": {\n \"api_key\": \"$var:g/all/api_key\",\n \"secret\": \"$var:u/admin/secret\"\n }\n}\n```\n\n**Reference formats:**\n- `$var:g/all/name` - Global variable\n- `$var:u/username/name` - User variable\n- `$var:f/folder/name` - Folder variable\n\n## Secrets\n\nNever put a secret (password, API key, token) inline in a resource value. Store it in a secret variable and reference that variable as `$var:`.\n\n- `$var:` is a reference, not a value: it resolves to the variable's value at run time. Never invent a value for a variable.\n- A resource that references a variable needs the variable to exist first, so create or deploy the variable before the resource.\n\n## Resource References\n\nReference other resources:\n\n```json\n{\n \"value\": {\n \"database\": \"$res:f/databases/postgres\"\n }\n}\n```\n\n## Passing a Resource or Variable as a Run Argument\n\nA script or flow argument typed as a resource (schema `format: resource-`) is passed as\nthe **bare string** `$res:` — the whole argument value. Same for a variable, with\n`$var:`. This applies everywhere job arguments are supplied: `wmill script run/preview`,\n`wmill flow run/preview`, the `runScriptByPath` / `runFlowByPath` API, a schedule's `args`, a\ntrigger's configured static args.\n\n```json\n{\n \"db\": \"$res:f/databases/postgres_prod\",\n \"api_token\": \"$var:g/all/api_token\"\n}\n```\n\nThe reference is resolved when the job runs, under the job's run-as identity — the caller for an\nordinary run, but the configured principal for a schedule, a trigger, or a runnable set to run on\nbehalf of someone else. The run fails if that identity cannot read the referenced resource or\nvariable.\n\n**Never wrap it in an object.** The resolver only rewrites a JSON value that *is* a string\nstarting with `$res:` / `$var:`; keys are never inspected. These are all wrong and are passed\nthrough to the script unchanged:\n\n```json\n{ \"db\": { \"$res\": \"f/databases/postgres_prod\" } }\n{ \"db\": { \"resource\": \"f/databases/postgres_prod\" } }\n{ \"db\": \"f/databases/postgres_prod\" }\n```\n\nThe string may sit anywhere a string can — a top-level argument, a nested object field\n(`{ \"gh_auth\": { \"token\": \"$var:g/all/gh_token\" } }`), or an array element (array elements are\nwalked only while nested at most two levels deep, and only for arrays of at most 1000 items).\nThe prefix must be on the string itself.\n\n## Common Resource Types\n\n### PostgreSQL\n```json\n{\n \"resource_type\": \"postgresql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 5432,\n \"user\": \"postgres\",\n \"password\": \"$var:g/all/pg_password\",\n \"dbname\": \"windmill\",\n \"sslmode\": \"prefer\"\n }\n}\n```\n\n### MySQL\n```json\n{\n \"resource_type\": \"mysql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 3306,\n \"user\": \"root\",\n \"password\": \"$var:g/all/mysql_password\",\n \"database\": \"myapp\"\n }\n}\n```\n\n### Slack\n```json\n{\n \"resource_type\": \"slack\",\n \"value\": {\n \"token\": \"$var:g/all/slack_token\"\n }\n}\n```\n\n### AWS S3\n```json\n{\n \"resource_type\": \"s3\",\n \"value\": {\n \"bucket\": \"my-bucket\",\n \"region\": \"us-east-1\",\n \"accessKeyId\": \"$var:g/all/aws_access_key\",\n \"secretAccessKey\": \"$var:g/all/aws_secret_key\"\n }\n}\n```\n\n### HTTP/API\n```json\n{\n \"resource_type\": \"http\",\n \"value\": {\n \"baseUrl\": \"https://api.example.com\",\n \"headers\": {\n \"Authorization\": \"Bearer $var:g/all/api_token\"\n }\n }\n}\n```\n\n### Kafka\n```json\n{\n \"resource_type\": \"kafka\",\n \"value\": {\n \"brokers\": \"broker1:9092,broker2:9092\",\n \"sasl_mechanism\": \"PLAIN\",\n \"security_protocol\": \"SASL_SSL\",\n \"username\": \"$var:g/all/kafka_user\",\n \"password\": \"$var:g/all/kafka_password\"\n }\n}\n```\n\n### NATS\n```json\n{\n \"resource_type\": \"nats\",\n \"value\": {\n \"servers\": [\"nats://localhost:4222\"],\n \"user\": \"$var:g/all/nats_user\",\n \"password\": \"$var:g/all/nats_password\"\n }\n}\n```\n\n### MQTT\n```json\n{\n \"resource_type\": \"mqtt\",\n \"value\": {\n \"host\": \"mqtt.example.com\",\n \"port\": 8883,\n \"username\": \"$var:g/all/mqtt_user\",\n \"password\": \"$var:g/all/mqtt_password\",\n \"tls\": true\n }\n}\n```\n\n## When No Resource Type Fits\n\nThe Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service:\n\n- If the instance is reported as not having synced the hub's types, mention that a superadmin can sync them: the hub most likely has one for the service. That holds for a type a hub script takes too: it is missing here until the sync, so say so before asking for a resource of it. Create one of your own only if the user still wants it, since after a sync the workspace would hold both.\n- Otherwise, or when the user prefers it to syncing, propose a custom resource type named `c_` holding the service's connection fields (base URL, account or region identifiers, credentials), as in \"Custom Resource Types\" below, and ask the user before creating it: every resource of that type and every script taking one depends on its fields.\n- A plain variable instead of a resource is fine when the user asks for it; without that, default to the resource, its secret in a variable it references.\n\n## Custom Resource Types\n\nA custom type's name starts with `c_`: the hub's types reach every instance under their plain names, so the prefix keeps a custom type from clashing with one synced later. Scripts and flows refer to the full name: `format: resource-c_custom_api`, `RT.CCustomApi` in TypeScript, a `c_custom_api` TypedDict in Python.\n\nCreate custom resource types with JSON Schema:\n\n```json\n{\n \"name\": \"c_custom_api\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"base_url\": {\"type\": \"string\", \"format\": \"uri\"},\n \"api_key\": {\"type\": \"string\"},\n \"timeout\": {\"type\": \"integer\", \"default\": 30}\n },\n \"required\": [\"base_url\", \"api_key\"]\n },\n \"description\": \"Custom API connection\"\n}\n```\n\nSave as: `c_custom_api.resource-type.json`\n\n## OAuth Resources\n\nOAuth resources are managed through the Windmill UI and marked:\n\n```json\n{\n \"is_oauth\": true,\n \"account\": 123\n}\n```\n\nOAuth tokens are automatically refreshed by Windmill.\n\n## Using Resources in Scripts\n\n### TypeScript (Bun/Deno)\n```typescript\nexport async function main(db: RT.Postgresql) {\n // db contains the resource values\n const { host, port, user, password, dbname } = db;\n}\n```\n\n### Python\n```python\nclass postgresql(TypedDict):\n host: str\n port: int\n user: str\n password: str\n dbname: str\n\ndef main(db: postgresql):\n # db contains the resource values\n pass\n```\n\n", "app": "# Global draft app instructions\n\n- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.\n- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to `u//` for bare names; only use `f//` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.\n- To create a new app, use `init_app` with a path, optional summary, a framework (`react19` / `react18` / `svelte5`) and, for an app that stores data, its `data` setup. \"Starting a new app\" below decides the framework and when the data setup is asked. Ask the user each of its questions, suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool. `init_app` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.\n- `init_app` seeds the starter inline runnables `greet`, `word_stats`, `slow_task`, `stream_text` so the demo tour works on first render. Replace or remove them once you start building real backend runnables.\n- Frontend file paths start with `/` (e.g. `/index.tsx`, `/App.tsx`, `/styles.css`). Use `write_app_file` / `patch_app_file` / `delete_app_file`.\n- Backend inline runnables are addressed as `backend//main.{ts|py}` from the file tools, but you create or update them via `write_app_runnable` / `delete_app_runnable` (which take the runnable shape directly: `{ name, type, inlineScript?, path?, staticInputs? }`).\n- `/wmill.d.ts` (or `wmill.ts`) is generated automatically from the backend runnables — never write it directly.\n- Inline runnables only support `bun` or `python3` in chat. Path runnables (`script`/`flow`/`hubscript`) reference an existing item.\n- When a path runnable points at a draft (see \"Draft code vs deployed code\" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.\n- The authoring reference below carries the TypeScript SDK. For a `python3` runnable, call `get_instructions` again with `subject: \"app\"` and `language: \"python3\"`.\n- Use `deploy_workspace_item` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.\n- Use `read_workspace_item` with `type: 'app'` for a metadata summary (file paths and runnable list, no contents). Use `read_app_file` to read an individual file; large files are truncated to a head slice, so pass `offset`/`limit` to page through the rest rather than re-reading the whole file.\n- To find where a symbol or string lives across the app, call `search_app` (greps every frontend file and inline runnable, returns matching `file:line` rows) instead of reading files one by one — then `read_app_file` only the ranges you need. The loop is list (`read_workspace_item`) → locate (`search_app`) → inspect (`read_app_file` with `offset`/`limit`).\n\n# Windmill raw app authoring reference\n\n# Windmill Raw Apps\n\nRaw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.\n\n## App shape\n\nA raw app has three logical parts:\n\n- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc.\n- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key.\n- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.\n\n## Starting a new app\n\nA new app gets the setup Windmill's own new-app dialog gives one: a framework, then where its data lives.\n\n### Framework\n\nUse the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.\n\n### Data setup\n\nWhen a new app has to store data and the user did not say where, **ask before creating anything**: no app, no schema, no table. Where the data lives is the user's decision, and tables created in the wrong place stay there. Look up the workspace's data tables first so every choice you offer exists, then ask, as proposed answers rather than open questions:\n\n1. **Data table**: which one to use, the default first (`main` when it exists, otherwise the first listed). Skip this one when the workspace has a single data table.\n2. **Schema**: where the app's new tables go.\n - A **new schema**, the usual choice, offered first. Suggest the first unused name among `app1`, `app2`, …\n - An **existing schema** of that data table, by name.\n - **None**: the tables go in `public`.\n3. **Tables**: only when that data table already holds tables the app could use, whether to **reuse** them (name them) or **create new ones**.\n\nDo not ask when:\n\n- the user already said where the data goes. A part they left out takes its default above: the default data table, a new `appN` schema, new tables;\n- the app, or its saved draft, already carries a data config (`data.datatable`, `data.schema` or `data.tables`). That config is the answer;\n- the app stores nothing.\n\nWith no data table in the workspace there is nothing to choose: say that one has to be configured in the workspace settings before the app can store data.\n\nA question left unanswered or dismissed is not a go-ahead for the defaults: stop there and say what is still needed.\n\nThen build what was chosen: the data table and schema become the app's `data.datatable` and `data.schema`, the tables it uses, reused or new, go in `data.tables`, and a new schema is created (`CREATE SCHEMA IF NOT EXISTS`) before the tables that go in it.\n\n### An app that arrives already set up\n\nAn app the user just started from the new-app dialog is already set up: its files are the framework template it was started with, and a `data` config it carries (`data.datatable`, `data.schema` or `data.tables`) is the data setup it was started with. Build that app in place, with that data config, and in that framework unless the user now names another. Do not ask for either, and do not create a second app. Only when it carries no data config and has to store data is the data setup still to ask.\n\n## Frontend\n\n### Entrypoint\n\nThe entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `
` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.\n\nReact (`index.tsx`):\n\n```tsx\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\ncreateRoot(document.getElementById('root')!).render()\n```\n\nSvelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.\n\n**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.\n\n**Set the browser tab title with `document.title = '...'`** (or a `` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.\n\n**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.\n\n### Generated bindings (`wmill.d.ts` / `wmill.ts`)\n\nThe frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.\n\n### Calling backend runnables\n\nImport the generated bindings and call the runnable like a function. `./wmill` is the **only** way the frontend reaches anything server-side — datatables, workspace items, external services. Never `fetch` the Windmill API from frontend code: the bundle holds no token and builds no API URL.\n\n| Export | Resolves to | Use it for |\n|---|---|---|\n| `backend.<key>(args)` | the runnable's result | the default — run and wait |\n| `backendAsync.<key>(args)` | the **job id** (a string) | long-running work you want to track |\n| `waitJob(jobId)` | the job's **result** (rejects if the job failed) | awaiting a `backendAsync` job |\n| `getJob(jobId)` | a `Job` (`{ type, success, result, duration_ms, ... }`) | polling status without blocking |\n| `streamJob(jobId, onUpdate?)` | the final result, calling `onUpdate` per chunk | showing output as it is produced |\n\nA runnable is always called with **one object** whose keys are its `main` parameters — `main(user_id: string, limit: number)` is called as `backend.get_users({ user_id, limit })`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the `wmill` client are paths (`u/<user>/<name>` or `f/<folder>/<name>`).\n\nRun and wait — the common case:\n\n```tsx\nimport { backend } from './wmill';\n\nconst user = await backend.get_user({ user_id: '123' });\n```\n\nStart a long job, then await it:\n\n```tsx\nimport { backendAsync, waitJob } from './wmill';\n\nconst jobId = await backendAsync.run_report({ month: '2026-08' }); // a string\nconst report = await waitJob(jobId); // the result itself\n```\n\nOr poll it without blocking, to render progress:\n\n```tsx\nimport { getJob } from './wmill';\n\nconst job = await getJob(jobId);\nif (job.type === 'CompletedJob') setReport(job.result);\n```\n\n`backendAsync` resolves a job id and nothing else — guard on it before storing or polling. A poll loop started on an `undefined` id never completes and shows as a row stuck \"running\" forever:\n\n```tsx\nconst jobId = await backendAsync.run_report(args);\nif (!jobId) throw new Error('run_report did not start a job');\n```\n\n**Never hand-write a job-polling runnable.** A backend runnable that calls `jobs/list`, or that returns `getResultMaybe(...)` for the frontend to poll, reimplements `backendAsync` + `waitJob` / `getJob` / `streamJob` — and it is what leads to guessing at base URLs and tokens.\n\n### Keeping data out of recorded demos\n\nAn app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:\n\n```tsx\n<label data-wm-no-record>\n Customer SSN <input value={ssn} onChange={onSsn} />\n</label>\n```\n\nApply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.\n\n### Chat UIs over a flow in chat mode or a saved agent\n\nA flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\n\nconst chat = useWindmillChat({ flowPath: 'f/support/assistant' })\n// chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop()\n```\n\n`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.\n\n#### A saved agent: an `agent` runnable\n\nChat with a saved AI agent (an `ai_agent` resource) through an `agent` runnable, a path runnable whose `path` is the agent's. Each call runs one turn of the agent's chat as the app, so a sandboxed app declares no frontend SDK scope, viewers get no consent prompt and need no access to the agent. Prefer it in every app, sandboxed or not:\n\n```json\n{ \"chat\": { \"type\": \"agent\", \"path\": \"f/support/agent\" } }\n```\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\nimport * as wmill from './wmill'\n\nconst chat = useWindmillChat({ wmill, runnable: 'chat' })\n```\n\n`runnable` is the runnable's key; each runnable keeps its own conversations. The runnable takes `user_message` 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", + "agent": "# Global draft agent instructions\n\n- An agent is drafted with `write_resource`: `{ path, resource_type: \"ai_agent\", description?, value }`, where `value` is the agent's configuration described below. To change an existing agent, read it with `read_workspace_item` type \"resource\" first and write back the whole value.\n- Paths follow the conventions in the system prompt: default to `u/<current-user>/<name>` for a bare name; only use `f/<folder>/<name>` when the folder is known to exist.\n- Test the draft with `test_run_agent` before handing it over, and fix what the run shows: a test runs the draft as it stands, its tools included. Reuse one `memory_id` across calls to test follow-ups.\n- The user can review the agent on its page, the url `write_resource` returns. Deploy it with `deploy_workspace_item` (type \"resource\") once they ask, as for any other draft.\n- Before writing an inline tool, look for a deployed script that already does the job with the workspace search tools.\n\n# Windmill agent authoring reference\n\n# Windmill AI Agents\n\nAn agent, as the Agents page lists it, is a saved LLM with its own system prompt, tools and\nmemory, stored as a resource of type `ai_agent`. It is built once and used in several places: it is\nrun from its own page (a chat, or a run form), a flow's AI agent step links to it by path, and it\ncan be shared in Slack channels from its page. Build a standalone agent when the user wants an\nassistant they talk to or reuse. Build a flow when the agent is one step of a larger process.\n\n## Structure\n\n```json\n{\n \"resource_type\": \"ai_agent\",\n \"description\": \"Answers support questions from the product docs\",\n \"value\": {\n \"provider\": { \"kind\": \"openai\", \"resource\": \"$res:f/ai_providers/openai\", \"model\": \"gpt-4o\" },\n \"system_prompt\": \"You are the support assistant for Acme. Answer from the docs; say so when they don't cover a question.\",\n \"memory\": { \"kind\": \"compaction\" },\n \"output_type\": \"text\",\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\": \"script\",\n \"path\": \"f/support/search_docs\",\n \"input_transforms\": {\n \"query\": { \"type\": \"ai\" },\n \"index\": { \"type\": \"static\", \"value\": \"product_docs\" }\n }\n }\n }\n ]\n }\n}\n```\n\nThe agent's own settings are plain values, not the `input_transforms` an agent step in a flow uses:\neach is fixed when the agent is saved. Only a tool's arguments keep `input_transforms` (see Tools). `$res:` and `$var:` references inside it are resolved at run time.\n\n- `provider` (required): `{ \"kind\", \"resource\": \"$res:<path>\", \"model\" }`, plus an optional\n `reasoning_effort`. `resource` names the workspace's AI provider resource of that kind\n- `system_prompt`: how the agent behaves. Write it for the agent's job: who it serves, what it may\n do with its tools, and what it should refuse or hand back\n- `memory`: what lets the agent see earlier turns. `{ \"kind\": \"compaction\" }` keeps the whole\n conversation, summarizing its older part as it nears the model's context window; it is what a new\n agent starts with on its page. `{ \"kind\": \"off\" }` makes every run start from nothing, right for an\n agent that handles one request per run\n- `output_type`: `text` (default) or `image`. Image output ignores tools and needs S3 storage on the\n workspace\n- `output_schema`: a JSON schema the answer must follow, for an agent whose output a program reads\n- `max_iterations`: how many model and tool rounds a run may take, 1 to 1000 (default 10)\n- `streaming` (default on), `temperature`, `max_completion_tokens`: as their names say\n- `run_on_behalf_of`: runs from the agent's page take the identity the agent was deployed with\n rather than the caller's, so people can use it without access to what its tools touch.\n `slack_anyone` lets anyone in the Slack channels it is shared in ask it, and is kept only with\n `run_on_behalf_of`. Set either only when the user asks: both widen who acts with the deployer's\n rights\n- `on_behalf_of` is the identity itself. Deploying sets it, so never write it\n\n## Tools\n\n`tools` takes the same entries as a flow's AI agent step. `value.tool_type` is `flowmodule` for a\nscript or inline script the agent calls (or a nested agent or AI decision), `mcp` for an MCP server\n(`{ \"tool_type\": \"mcp\", \"resource_path\": \"$res:<path>\", \"include_tools\": [], \"exclude_tools\": [] }`),\nor `websearch` for web search (`{ \"tool_type\": \"websearch\" }`, summary `Web Search`).\n\n- Write logic made for this agent as an inline `rawscript` tool, as a flow writes its steps. Point a\n `script` tool at a deployed script (`path`) only when one already does the job: a run calls the\n deployed version, so a script that exists only as a draft fails the run. An inline tool's\n `value` is a flow's rawscript step:\n `{ \"tool_type\": \"flowmodule\", \"type\": \"rawscript\", \"language\": \"bun\", \"content\": \"export async function main(query: string) { ... }\", \"input_transforms\": { \"query\": { \"type\": \"ai\" } } }`\n- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script\n that runs the flow\n- A flowmodule tool's `summary` is the name the agent calls it by: letters, numbers and underscores\n only, unique among the agent's tools, and neither a reserved step id (a JavaScript keyword, `ctx`,\n `state`, `bg`) nor `__wm_web_search`. Put the explanation in `description`, which tells the agent\n when to call it\n- Each argument of a tool is either the model's or fixed. `{ \"type\": \"ai\" }` (or no value) lets the\n model fill it; a static value fixes it and hides it from the model. Fix what the model must not\n choose, such as a resource, a table name or a limit\n\n## Using It From a Flow\n\nAn AI agent step links to a saved agent with `value.agent` instead of carrying its own `provider`,\nsystem prompt and tools:\n\n```json\n{\n \"id\": \"support\",\n \"value\": {\n \"type\": \"aiagent\",\n \"agent\": \"f/support/support_agent\",\n \"input_transforms\": {\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.question\" }\n },\n \"tools\": []\n }\n}\n```\n\nThe step holds only what belongs to one use of the agent: `user_message`, `user_attachments`,\n`enabled_tools`, `memory_id`, `previous_messages`, and the `model` and `reasoning_effort` overrides.\nIts `value.tool_inputs` (`{ \"<tool id>\": { \"<argument>\": <input transform> } }`) wires a saved\ntool's arguments to this flow, e.g. `flow_input.customer_id`, over the transforms the agent saved,\nwithout changing the agent. Everything else comes from the agent, so a change to the agent reaches\nevery flow linking it.\n", "datatable": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement<T> = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record<string, any>;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch<ResultCollectionT extends ResultCollection = \"last_statement_all_rows\">(\n params?: FetchParams<ResultCollectionT | ResultCollection> // The union is for auto-completion\n ): Promise<SqlResult<T, ResultCollectionT>>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<SqlResult<T, \"last_statement_first_row\">>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise<SqlResult<T, \"last_statement_first_row_scalar\">>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<void>;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n <T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;\n query<T = any>(sql: string, ...params: any[]): SqlStatement<T>;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@param opts.role - Connect as this data table role instead of the data table's default one.\n Only meaningful on a data table under roles, and only for a role you are a tenant of.\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n@example\n// Read through a restricted role\nlet sql = wmill.datatable(\"main\", { role: \"analytics\" })\n```typescript\nfunction datatable(name: string = \"main\",\n opts?: DatatableOptions): DatatableSqlTemplateFunction\n```\n", "datatable.python3": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n # role: Data table role to connect as, or None for the data table's default\n def __init__(client: Windmill, name: str, role: Optional[str] = None)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n", "datatable.go": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement<T> = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record<string, any>;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch<ResultCollectionT extends ResultCollection = \"last_statement_all_rows\">(\n params?: FetchParams<ResultCollectionT | ResultCollection> // The union is for auto-completion\n ): Promise<SqlResult<T, ResultCollectionT>>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<SqlResult<T, \"last_statement_first_row\">>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise<SqlResult<T, \"last_statement_first_row_scalar\">>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<void>;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n <T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;\n query<T = any>(sql: string, ...params: any[]): SqlStatement<T>;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@param opts.role - Connect as this data table role instead of the data table's default one.\n Only meaningful on a data table under roles, and only for a role you are a tenant of.\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n@example\n// Read through a restricted role\nlet sql = wmill.datatable(\"main\", { role: \"analytics\" })\n```typescript\nfunction datatable(name: string = \"main\",\n opts?: DatatableOptions): DatatableSqlTemplateFunction\n```\n\n\n## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n # role: Data table role to connect as, or None for the data table's default\n def __init__(client: Windmill, name: str, role: Optional[str] = None)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n", diff --git a/backend/windmill-api/src/mcp/session/gen/prompt.json b/backend/windmill-api/src/mcp/session/gen/prompt.json index b3ffea17b7..c043d1e872 100644 --- a/backend/windmill-api/src/mcp/session/gen/prompt.json +++ b/backend/windmill-api/src/mcp/session/gen/prompt.json @@ -1,5 +1,5 @@ { - "base": "You are working in a Windmill workspace through its MCP server.\n\nThe user sees no Windmill page next to this conversation. Tool results carry links instead: a run's page, a script, flow or agent draft's url (its editor), an app draft's preview_url (the app editor with its live preview, which these instructions call \"the preview\"), a deployed item's url. Whenever the user will want to see what you built or ran, or has to do a step in Windmill themselves, open the link in a browser they can see from this conversation if you have one, and otherwise give them the link to open.\n\nThe current user's workspace username is \"{{username}}\".\n\nUse tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.\n\nRules:\n- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.\n- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.\n- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.\n- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.\n- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.\n- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version (\"the deployed X\", \"in production\", \"for real\") — a bare \"run X\" is not that. For those two, read the item with read_workspace_item version: \"deployed\" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow run with exactly the arguments you send, so fill in every argument the schema asks for. test_run_step takes the step's own inputs, not the flow's.\n\nPath conventions:\n- A workspace path starts with one of two namespaces; its trailing <name> may itself contain \"/\", so a path has three or more segments:\n - `u/{{username}}/<name>` — your personal scope. Default for ad-hoc, exploratory, or scratch work.\n - `f/<folder>/<name>` — a shared folder scope; the <folder> must already exist (a bare `f/<name>` with no folder segment is INVALID and will fail).\n- If the user supplies a fully qualified `f/<folder>/...` path, use that exact path; they have already chosen the folder. Do not ask for folder confirmation or substitute a `u/{{username}}/...` path unless a tool rejects it.\n- Default a bare name with no namespace prefix (e.g. \"create a flow called myflow\") to `u/{{username}}/<name>`. Never invent an `f/<folder>/...` path for a folder that does not exist; create one with `create_folder` only when the user explicitly asks for a new folder.\n{{folder_guidance}}\nMore rules:\n- Listings and reads return only what this user can see: items in folders they cannot read are hidden from you both. When an item the user names is missing, say it is not among what they can see and may be in a folder they cannot read, where someone with access can run it — never state that it does not exist or is not deployed.\n- An agent, as the Agents page lists it, is a resource of type `ai_agent`: read it with read_workspace_item type \"resource\". You may draft a change to an agent, but never deploy or rename one here: the user reviews and deploys it from the agent's page, `/agents/edit/<path>`, so point them there.\n- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so \"delete it\" / \"never mind\" / \"remove that\" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.\n- Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against=\"parent_workspace\" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.\n- You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. \"$var:path/to/variable\" is how a resource value references a variable — it is never a variable's own value.\n- Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition.\n- When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it.\n- Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under `hub/<version>/<app>/<name>` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type \"script\" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its `integration` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the `suggested_integrations` a search hands back when it finds nothing.\n- Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration `documented` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API.\n- If you have a web search tool and the hub does not cover a third-party API, search for the vendor's own API documentation rather than writing its endpoints and auth from memory, and link the page you relied on. Reserve it for external APIs: search_docs answers questions about Windmill itself.\n- create_resource_type creates a resource type the workspace lacks. Show the user the schema you propose and create it once they agree.\n- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n- A \"data pipeline\" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file `pipeline` / `on <ref>` annotation comments written in each script's comment syntax (`--` for SQL, `#` for Python/Bash, `//` for TS — a `//` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject \"pipeline\" and build annotated script drafts — do not build a flow. list_pipelines lists the existing ones by folder.\n- Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.\n- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run.\n- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars).\n- Keep context targeted.\n\nDocumentation:\n- Use search_docs to look up how a Windmill feature works in the official documentation (a flag, concept, function, or \"does Windmill support X\") instead of guessing about product behavior. It returns matching doc snippets with their Source URL; call read_docs_page with a Source URL to read the full page (or a section, if it returns headings). Cite the Source URL when you rely on it.\n- Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible.\n- If the user asks about something that you are unsure about, say that you are not sure about the answer and suggest to ask the question to the windmill team.\n- If the first search returns nothing useful, retry with different or broader keywords before giving up.\n- If the documentation does not cover the user's question, say so clearly rather than inventing an answer, and suggest asking the Windmill team.\n\nFlows:\n- read_workspace_item returns compact flow JSON. Inline script bodies appear as \"inline_script.<moduleId>\".\n- Use read_flow_module_code and set_flow_module_code for inline script bodies.\n- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.\n\nRaw apps:\n- The app tools below only work on raw (code) apps. `rawApp` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app.\n- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.\n- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.\n- Use write_app_file, patch_app_file, and delete_app_file for frontend files.\n- Use write_app_runnable and delete_app_runnable for backend runnables.\n- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. When the app has to store data and the user did not say where, settle the data setup with the user BEFORE init_app or any SQL (get_instructions subject \"app\" lists the questions), then pass the outcome as init_app's data.\n- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.\n\nData Tables:\n- Datatables are workspace-scoped managed PostgreSQL databases, shared across the workspace (not owned by any single app). They must be configured by the user in their workspace settings (Workspace settings → Data Tables); they cannot be created via SQL.\n- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a \"main\" datatable exists or call exec_datatable_sql.\n- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.\n- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.\n- A datatable that list_datatables reports with `migrations_enabled` keeps its schema as versioned migrations: change its schema with create_datatable_migration, never exec_datatable_sql. list_datatable_migrations reads them, run_datatable_migrations applies what is pending, rollback_datatable_migration undoes one. A migration is applied for real, not drafted, so call get_instructions with subject \"datatable\" before your first one.\n- A datatable can be under roles; list_datatables then reports its `usable_roles` and `default_role`. When working on a raw app that pins a role (`data.roles` in its raw_app.yaml), pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can. For anything else about roles, grants or a `permission denied`, call get_instructions with subject \"datatable\".\n- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject \"datatable\" and language \"bun\" for the TypeScript SQL SDK reference (or language \"python3\" for Python) — it returns only that language so you get just what you need.", + "base": "You are working in a Windmill workspace through its MCP server.\n\nThe user sees no Windmill page next to this conversation. Tool results carry links instead: a run's page, a script, flow or agent draft's url (its editor), an app draft's preview_url (the app editor with its live preview, which these instructions call \"the preview\"), a deployed item's url. Whenever the user will want to see what you built or ran, or has to do a step in Windmill themselves, open the link in a browser they can see from this conversation if you have one, and otherwise give them the link to open.\n\nThe current user's workspace username is \"{{username}}\".\n\nUse tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.\n\nRules:\n- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.\n- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.\n- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.\n- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.\n- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.\n- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version (\"the deployed X\", \"in production\", \"for real\") — a bare \"run X\" is not that. For those two, read the item with read_workspace_item version: \"deployed\" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow run with exactly the arguments you send, so fill in every argument the schema asks for. test_run_step takes the step's own inputs, not the flow's.\n\nPath conventions:\n- A workspace path starts with one of two namespaces; its trailing <name> may itself contain \"/\", so a path has three or more segments:\n - `u/{{username}}/<name>` — your personal scope. Default for ad-hoc, exploratory, or scratch work.\n - `f/<folder>/<name>` — a shared folder scope; the <folder> must already exist (a bare `f/<name>` with no folder segment is INVALID and will fail).\n- If the user supplies a fully qualified `f/<folder>/...` path, use that exact path; they have already chosen the folder. Do not ask for folder confirmation or substitute a `u/{{username}}/...` path unless a tool rejects it.\n- Default a bare name with no namespace prefix (e.g. \"create a flow called myflow\") to `u/{{username}}/<name>`. Never invent an `f/<folder>/...` path for a folder that does not exist; create one with `create_folder` only when the user explicitly asks for a new folder.\n{{folder_guidance}}\nMore rules:\n- Listings and reads return only what this user can see: items in folders they cannot read are hidden from you both. When an item the user names is missing, say it is not among what they can see and may be in a folder they cannot read, where someone with access can run it — never state that it does not exist or is not deployed.\n- An agent, as the Agents page lists it, is a resource of type `ai_agent`: read it with read_workspace_item type \"resource\". You may draft a new agent or a change to one, after calling get_instructions with subject \"agent\". The user can review it on the agent's page, the url write_resource returns. Test an agent, its draft included, with test_run_agent. Run a deployed agent with run_agent, never with run_flow or run_script.\n- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so \"delete it\" / \"never mind\" / \"remove that\" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.\n- Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against=\"parent_workspace\" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.\n- You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. \"$var:path/to/variable\" is how a resource value references a variable — it is never a variable's own value.\n- Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition.\n- When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it.\n- Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under `hub/<version>/<app>/<name>` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type \"script\" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its `integration` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the `suggested_integrations` a search hands back when it finds nothing.\n- Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration `documented` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API.\n- If you have a web search tool and the hub does not cover a third-party API, search for the vendor's own API documentation rather than writing its endpoints and auth from memory, and link the page you relied on. Reserve it for external APIs: search_docs answers questions about Windmill itself.\n- create_resource_type creates a resource type the workspace lacks. Show the user the schema you propose and create it once they agree.\n- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n- A \"data pipeline\" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file `pipeline` / `on <ref>` annotation comments written in each script's comment syntax (`--` for SQL, `#` for Python/Bash, `//` for TS — a `//` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject \"pipeline\" and build annotated script drafts — do not build a flow. list_pipelines lists the existing ones by folder.\n- Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.\n- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run.\n- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars).\n- Keep context targeted.\n\nDocumentation:\n- Use search_docs to look up how a Windmill feature works in the official documentation (a flag, concept, function, or \"does Windmill support X\") instead of guessing about product behavior. It returns matching doc snippets with their Source URL; call read_docs_page with a Source URL to read the full page (or a section, if it returns headings). Cite the Source URL when you rely on it.\n- Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible.\n- If the user asks about something that you are unsure about, say that you are not sure about the answer and suggest to ask the question to the windmill team.\n- If the first search returns nothing useful, retry with different or broader keywords before giving up.\n- If the documentation does not cover the user's question, say so clearly rather than inventing an answer, and suggest asking the Windmill team.\n\nFlows:\n- read_workspace_item returns compact flow JSON. Inline script bodies appear as \"inline_script.<moduleId>\".\n- Use read_flow_module_code and set_flow_module_code for inline script bodies.\n- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.\n\nRaw apps:\n- The app tools below only work on raw (code) apps. `rawApp` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app.\n- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.\n- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.\n- Use write_app_file, patch_app_file, and delete_app_file for frontend files.\n- Use write_app_runnable and delete_app_runnable for backend runnables.\n- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. When the app has to store data and the user did not say where, settle the data setup with the user BEFORE init_app or any SQL (get_instructions subject \"app\" lists the questions), then pass the outcome as init_app's data.\n- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.\n\nData Tables:\n- Datatables are workspace-scoped managed PostgreSQL databases, shared across the workspace (not owned by any single app). They must be configured by the user in their workspace settings (Workspace settings → Data Tables); they cannot be created via SQL.\n- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a \"main\" datatable exists or call exec_datatable_sql.\n- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.\n- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.\n- A datatable that list_datatables reports with `migrations_enabled` keeps its schema as versioned migrations: change its schema with create_datatable_migration, never exec_datatable_sql. list_datatable_migrations reads them, run_datatable_migrations applies what is pending, rollback_datatable_migration undoes one. A migration is applied for real, not drafted, so call get_instructions with subject \"datatable\" before your first one.\n- A datatable can be under roles; list_datatables then reports its `usable_roles` and `default_role`. When working on a raw app that pins a role (`data.roles` in its raw_app.yaml), pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can. For anything else about roles, grants or a `permission denied`, call get_instructions with subject \"datatable\".\n- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject \"datatable\" and language \"bun\" for the TypeScript SQL SDK reference (or language \"python3\" for Python) — it returns only that language so you get just what you need.", "folder_guidance": { "admin": "- As a workspace admin you can write to any existing folder. Folders here include {{writable_folders}} (you can also write to others not listed; list_workspace_items with type \"folder\" lists them all). If the user names a folder, use it; if they explicitly ask for a new folder, create it with `create_folder`; otherwise ask them which folder to use rather than guessing or creating one unprompted.\n", "admin_no_folders": "- As a workspace admin you can write to any existing folder. If the user names a folder, use it; if they explicitly ask for a new folder, create it with `create_folder`; otherwise ask them which folder to use rather than guessing or creating one unprompted.\n", diff --git a/backend/windmill-api/src/mcp/session/gen/tools.json b/backend/windmill-api/src/mcp/session/gen/tools.json index da1d9cb32a..c48ff61b60 100644 --- a/backend/windmill-api/src/mcp/session/gen/tools.json +++ b/backend/windmill-api/src/mcp/session/gen/tools.json @@ -19,7 +19,7 @@ }, { "name": "open_page", - "description": "Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. \"show me the failed runs of X\", \"open the schedule for Y\", \"open the git sync settings\", \"open the kafka triggers\"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource's credentials, set a variable's value — pass open with the item path so its editor opens directly). Never offer page \"compare\" for draft review. Use it only when the user explicitly asks to deploy a forked workspace into its parent; it always opens the fork-vs-parent comparison. This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.", + "description": "Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. \"show me the failed runs of X\", \"open the schedule for Y\", \"open the git sync settings\", \"open the kafka triggers\"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource's credentials, set a variable's value — pass open with the item path so its editor opens directly). Never offer page \"compare\" for draft review. Use it only when the user explicitly asks to deploy a forked workspace into its parent; it always opens the fork-vs-parent comparison. This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, agents, pipelines). Only pages listed for this user are available; do not offer others.", "parameters": { "type": "object", "properties": { @@ -211,18 +211,19 @@ }, { "name": "get_instructions", - "description": "Get authoring guidance for scripts, flows, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.", + "description": "Get authoring guidance for scripts, flows, AI agents, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.", "parameters": { "type": "object", "properties": { "subject": { - "description": "What to get authoring instructions for: a workspace item type (script, flow, resource, app), \"pipeline\" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or \"datatable\" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables, or \"workspace_settings\" for the keys and rules of the workspace settings (admins only). Schedules, triggers, and variables don't need instructions — their tool schemas describe everything.", + "description": "What to get authoring instructions for: a workspace item type (script, flow, resource, app), \"agent\" for a saved AI agent (an `ai_agent` resource, as the Agents page lists it), \"pipeline\" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or \"datatable\" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables, or \"workspace_settings\" for the keys and rules of the workspace settings (admins only). Schedules, triggers, and variables don't need instructions — their tool schemas describe everything.", "type": "string", "enum": [ "script", "flow", "resource", "app", + "agent", "datatable", "pipeline", "workspace_settings" @@ -1179,6 +1180,96 @@ }, "read_only": false }, + { + "name": "test_run_agent", + "description": "Execute a test run of an AI agent by path, preferring its draft when one exists, so an agent you drafted can be tried before the user deploys it. It runs with exactly the `args` you send.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the agent (`ai_agent` resource) to test.", + "type": "string" + }, + "args": { + "description": "The run's inputs: `user_message`, and `user_attachments` for files the user attaches.", + "type": "object", + "properties": { + "user_message": { + "description": "The message the agent answers.", + "type": "string" + } + }, + "required": [ + "user_message" + ], + "additionalProperties": {} + }, + "memory_id": { + "description": "A UUID naming the conversation this turn belongs to: reuse the same one across calls to test memory and follow-ups, and omit it for a one-off turn in a conversation of its own. Generate the UUID yourself so you can pass it again.", + "type": "string" + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path", + "args" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "run_agent", + "description": "Run a DEPLOYED AI agent for real, as its own page runs it. It runs with exactly the `args` you send. Use only when the user names the deployed version (\"the deployed X\", \"in production\", \"for real\").", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the deployed agent (`ai_agent` resource) to run.", + "type": "string" + }, + "args": { + "description": "The run's inputs: `user_message`, and `user_attachments` for files the user attaches.", + "type": "object", + "properties": { + "user_message": { + "description": "The message the agent answers.", + "type": "string" + } + }, + "required": [ + "user_message" + ], + "additionalProperties": {} + }, + "memory_id": { + "description": "A UUID naming the conversation this turn belongs to: reuse the same one across calls to test memory and follow-ups, and omit it for a one-off turn in a conversation of its own. Generate the UUID yourself so you can pass it again.", + "type": "string" + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path", + "args" + ], + "additionalProperties": false + }, + "read_only": false + }, { "name": "test_run_step", "description": "Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. An AI agent step takes the inputs a run supplies rather than code arguments, `user_message` above all; its form opens on the step's own configuration, so send only what this run should change. An AI decision step takes `state`, what its questions are asked about; its provider and questions are the step's own. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", diff --git a/backend/windmill-api/src/mcp/session/mod.rs b/backend/windmill-api/src/mcp/session/mod.rs index 607c6be5f8..d00ea10e0a 100644 --- a/backend/windmill-api/src/mcp/session/mod.rs +++ b/backend/windmill-api/src/mcp/session/mod.rs @@ -116,8 +116,8 @@ const DESTRUCTIVE: &[&str] = &[ /// nothing on the tools server, so it runs nothing here either. const RUNS_A_JOB: &[&str] = &["get_db_schema"]; -/// Tools that run a deployed script or flow, the one write a read-and-run token keeps. -const RUNS_DEPLOYED: &[&str] = &["run_script", "run_flow"]; +/// Tools that run a deployed script, flow or agent, the one write a read-and-run token keeps. +const RUNS_DEPLOYED: &[&str] = &["run_script", "run_flow", "run_agent"]; fn allowed(def: &ToolDef, access: SessionAccess) -> bool { let read = def.read_only && !RUNS_A_JOB.contains(&def.name.as_str()); @@ -368,7 +368,7 @@ fn guarded_url( // A run also stores its secret arguments as expiring variables; the write tools that // could create other variables are not offered to a read-and-run token. let run_writes = method == "POST" - && ["jobs/run/p/", "jobs/run/f/"] + && ["jobs/run/p/", "jobs/run/f/", "jobs/run/agent/"] .iter() .any(|run| route.starts_with(&format!("/w/{w_id}/{run}"))) || method == "POST" && route == format!("/w/{w_id}/variables/create"); @@ -582,6 +582,7 @@ mod tests { // preview takes code. assert!(url("POST", "/w/ws/jobs/run/p/u/a/b", ReadAndRun).is_ok()); assert!(url("POST", "/w/ws/jobs/run/f/u/a/b", ReadAndRun).is_ok()); + assert!(url("POST", "/w/ws/jobs/run/agent/u/a/b", ReadAndRun).is_ok()); assert!(url("POST", "/w/ws/jobs/run/preview", ReadAndRun).is_err()); assert!(url("POST", "/w/ws/drafts/update", ReadAndRun).is_err()); assert!(url("POST", "/w/ws/variables/create", ReadAndRun).is_ok()); diff --git a/backend/windmill-api/src/mcp/session/runs.rs b/backend/windmill-api/src/mcp/session/runs.rs index 10f1c7e224..e3d3b87768 100644 --- a/backend/windmill-api/src/mcp/session/runs.rs +++ b/backend/windmill-api/src/mcp/session/runs.rs @@ -17,6 +17,8 @@ pub(super) const TOOLS: &[&str] = &[ "test_run_flow", "run_flow", "test_run_step", + "test_run_agent", + "run_agent", "list_runs", "get_run", "cancel_job", @@ -29,6 +31,8 @@ pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult "test_run_flow" => test_run_flow(ctx, args).await, "run_flow" => run_deployed(ctx, args, true).await, "test_run_step" => test_run_step(ctx, args).await, + "test_run_agent" => test_run_agent(ctx, args).await, + "run_agent" => run_agent(ctx, args).await, "list_runs" => list_runs(ctx, args).await, "get_run" => get_run(ctx, args).await, "cancel_job" => cancel_job(ctx, args).await, @@ -707,6 +711,129 @@ async fn test_run_flow(ctx: &Ctx<'_>, args: &Value) -> ToolResult { conclude(ctx, args, prepared.notes, started, path, true, "test").await } +// ============================================================================ +// Saved AI agents +// ============================================================================ + +/// What a run of an agent takes, as its editor's chat declares it. +fn agent_run_schema() -> Value { + json!({ + "type": "object", + "properties": { + "user_message": { "type": "string", "description": "Message from user" }, + "user_attachments": { + "type": "array", + "items": { "type": "object", "resourceType": "s3object" }, + "description": "Images or PDFs for the agent to read" + } + }, + "required": ["user_message"] + }) +} + +fn agent_memory_id(args: &Value) -> Result<Option<&str>, String> { + let memory_id = opt_str_arg(args, "memory_id"); + if memory_id.is_some_and(|id| id.len() != 36 || uuid::Uuid::parse_str(id).is_err()) { + return Err("memory_id must be a UUID".to_string()); + } + Ok(memory_id) +} + +/// The agent's configuration a test runs: the user's draft when there is one, else the deployed +/// resource. Refuses a path holding some other kind of resource. +async fn load_agent_for_test(ctx: &Ctx<'_>, path: &str) -> Result<Value, String> { + let draft = get_draft(ctx, "resource", path).await?.map(|d| d.value); + // A draft records its type only when nothing is deployed at its path. + let (resource_type, config) = match draft { + Some(draft) if draft.get("resource_type").is_some() => { + (draft["resource_type"].clone(), draft["args"].clone()) + } + draft => { + let deployed = ctx + .get(&ctx.ws(&format!("resources/get/{}", path_enc(path)))) + .await?; + let config = match draft { + Some(draft) => draft["args"].clone(), + None => deployed["value"].clone(), + }; + (deployed["resource_type"].clone(), config) + } + }; + let resource_type = resource_type.as_str().unwrap_or_default(); + if resource_type != "ai_agent" { + return Err(format!( + "\"{path}\" is a {resource_type} resource, not an AI agent." + )); + } + Ok(config) +} + +async fn test_run_agent(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let memory_id = agent_memory_id(args)?; + let mut config = load_agent_for_test(ctx, path).await?; + // Who the agent runs as and who may ask it in Slack, not inputs: a test runs as its caller. + if let Some(map) = config.as_object_mut() { + for key in ["on_behalf_of", "run_on_behalf_of", "slack_anyone"] { + map.remove(key); + } + } + // The run the agent editor's chat makes of its draft, which keeps none of the inputs a flow + // step supplies (`agentChatFlow`). + let flow = crate::ai_evals::run::config_to_draft(config) + .and_then(|config| { + crate::agent_runs::agent_step_flow( + &config, + crate::agent_runs::AGENT_NODE_ID, + true, + &["memory_id", "previous_messages", "enabled_tools"], + ) + }) + .map_err(|e| e.to_string())?; + let flow = serde_json::to_value(flow).map_err(|e| e.to_string())?; + let schema = agent_run_schema(); + let prepared = match prepare_args(ctx, path, proposed_args(args), Some(&schema), "agent").await + { + Ok(prepared) => prepared, + Err(message) => return Ok(message), + }; + let memory_id = memory_id + .map(String::from) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + // Filed where the agent editor's chat files its conversations. + let started = run_flow_preview( + ctx, + Some(&format!("{path}.chat")), + &flow, + Value::Object(prepared.args), + Some(&memory_id), + ) + .await; + conclude(ctx, args, prepared.notes, started, path, true, "test").await +} + +async fn run_agent(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let memory_id = agent_memory_id(args)?; + let schema = agent_run_schema(); + let prepared = match prepare_args(ctx, path, proposed_args(args), Some(&schema), "agent").await + { + Ok(prepared) => prepared, + Err(message) => return Ok(message), + }; + let query = memory_id + .map(|id| format!("?memory_id={}", enc(id))) + .unwrap_or_default(); + let started = ctx + .post( + &ctx.ws(&format!("jobs/run/agent/{}{query}", path_enc(path))), + Value::Object(prepared.args), + ) + .await + .map(job_id); + conclude(ctx, args, prepared.notes, started, path, true, "run").await +} + // ============================================================================ // One step of a flow // ============================================================================ diff --git a/backend/windmill-mcp/src/common/scope.rs b/backend/windmill-mcp/src/common/scope.rs index 642c02a2bc..b60c9821be 100644 --- a/backend/windmill-mcp/src/common/scope.rs +++ b/backend/windmill-mcp/src/common/scope.rs @@ -23,7 +23,7 @@ pub struct McpScopeConfig { /// The workspaces a token without a bound workspace may act in /// (`mcp:workspaces:a,b`); `None` is every workspace its owner belongs to. pub workspaces: Option<Vec<String>>, - /// `mcp:read_run`: reads, plus running deployed scripts and flows, and nothing else. + /// `mcp:read_run`: reads, plus running deployed scripts, flows and agents, and nothing else. pub read_run: bool, /// `mcp:read_only`: reads alone, as the token's read-only flag does. An OAuth grant /// carries scopes and no flag, so this is how a sign-in asks for read-only access. diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index dc3a23bb51..f98637fb8d 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -17,8 +17,8 @@ use crate::server::endpoints::EndpointTool; /// Result type for backend operations using rmcp's ErrorData directly pub type BackendResult<T> = Result<T, ErrorData>; -/// What a v2 token may do: read, read and run deployed scripts and flows (`mcp:read_run`), -/// or everything its owner can. +/// What a v2 token may do: read, read and run deployed scripts, flows and agents +/// (`mcp:read_run`), or everything its owner can. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SessionAccess { ReadOnly, diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index c01bad21b7..70ecb3c70a 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -32,6 +32,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "triggers", description: "MUST use when configuring triggers." }, { name: "schedules", description: "MUST use when configuring schedules." }, { name: "resources", description: "MUST use when managing resources." }, + { name: "agents", description: "MUST use when creating or changing an AI agent, the reusable `ai_agent` resource the Agents page lists." }, { name: "write-workflow-as-code", description: "MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow." }, { name: "write-pipeline", description: "MUST use when creating or modifying a data pipeline, a set of scripts marked `pipeline` and wired together by `on` / `materialize` annotations." }, { name: "datatables", description: "MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions." }, @@ -5911,11 +5912,12 @@ value: ## AI Agent Modules 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, \`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. +object with an extra \`value.tool_type\`: \`flowmodule\` for a script or inline script tool (or a nested +agent or AI decision), \`mcp\` for an MCP server 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 { @@ -5942,7 +5944,7 @@ tool to have a person approve or reject each of its calls before it runs. "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { return ['doc1', 'doc2']; }", - "input_transforms": { "query": { "type": "static", "value": "" } } + "input_transforms": { "query": { "type": "ai" } } } } ] @@ -5950,6 +5952,11 @@ tool to have a person approve or reject each of its calls before it runs. } \`\`\` +- A flow cannot be a tool: a run offered one fails with \`Unsupported tool\`. Wrap it in a script + that runs the flow +- Each argument of a tool is either the model's or fixed. \`{ "type": "ai" }\` (or no value) lets the + model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix + what the model must not choose, such as a resource, a table name or a limit - \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>, "resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its @@ -7220,6 +7227,7 @@ Example: \`f/databases/postgres_prod.resource.json\` An agent, as the Agents page lists it, is a resource of type \`ai_agent\`. Its value holds the agent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by linking its path. +Before writing one, use the \`agents\` skill. ## Variable References @@ -7487,6 +7495,126 @@ wmill resource-type get postgresql # deploy via \`git push\` or \`wmill sync push\` (see the Deploying section in AGENTS.wmill.md). wmill sync push \`\`\` +`, + "agents": `--- +name: agents +description: MUST use when creating or changing an AI agent, the reusable \`ai_agent\` resource the Agents page lists. +--- + +# Windmill AI Agents + +An agent, as the Agents page lists it, is a saved LLM with its own system prompt, tools and +memory, stored as a resource of type \`ai_agent\`. It is built once and used in several places: it is +run from its own page (a chat, or a run form), a flow's AI agent step links to it by path, and it +can be shared in Slack channels from its page. Build a standalone agent when the user wants an +assistant they talk to or reuse. Build a flow when the agent is one step of a larger process. + +## File Format + +An agent is a resource file like any other, \`{path}.resource.yaml\` (or \`.resource.json\`), with +\`resource_type: ai_agent\`. + +## Structure + +\`\`\`json +{ + "resource_type": "ai_agent", + "description": "Answers support questions from the product docs", + "value": { + "provider": { "kind": "openai", "resource": "$res:f/ai_providers/openai", "model": "gpt-4o" }, + "system_prompt": "You are the support assistant for Acme. Answer from the docs; say so when they don't cover a question.", + "memory": { "kind": "compaction" }, + "output_type": "text", + "tools": [ + { + "id": "search_docs", + "summary": "search_documentation", + "description": "Search the product documentation. Use it whenever the user asks how a feature works.", + "value": { + "tool_type": "flowmodule", + "type": "script", + "path": "f/support/search_docs", + "input_transforms": { + "query": { "type": "ai" }, + "index": { "type": "static", "value": "product_docs" } + } + } + } + ] + } +} +\`\`\` + +The agent's own settings are plain values, not the \`input_transforms\` an agent step in a flow uses: +each is fixed when the agent is saved. Only a tool's arguments keep \`input_transforms\` (see Tools). \`$res:\` and \`$var:\` references inside it are resolved at run time. + +- \`provider\` (required): \`{ "kind", "resource": "$res:<path>", "model" }\`, plus an optional + \`reasoning_effort\`. \`resource\` names the workspace's AI provider resource of that kind +- \`system_prompt\`: how the agent behaves. Write it for the agent's job: who it serves, what it may + do with its tools, and what it should refuse or hand back +- \`memory\`: what lets the agent see earlier turns. \`{ "kind": "compaction" }\` keeps the whole + conversation, summarizing its older part as it nears the model's context window; it is what a new + agent starts with on its page. \`{ "kind": "off" }\` makes every run start from nothing, right for an + agent that handles one request per run +- \`output_type\`: \`text\` (default) or \`image\`. Image output ignores tools and needs S3 storage on the + workspace +- \`output_schema\`: a JSON schema the answer must follow, for an agent whose output a program reads +- \`max_iterations\`: how many model and tool rounds a run may take, 1 to 1000 (default 10) +- \`streaming\` (default on), \`temperature\`, \`max_completion_tokens\`: as their names say +- \`run_on_behalf_of\`: runs from the agent's page take the identity the agent was deployed with + rather than the caller's, so people can use it without access to what its tools touch. + \`slack_anyone\` lets anyone in the Slack channels it is shared in ask it, and is kept only with + \`run_on_behalf_of\`. Set either only when the user asks: both widen who acts with the deployer's + rights +- \`on_behalf_of\` is the identity itself. Deploying sets it, so never write it + +## Tools + +\`tools\` takes the same entries as a flow's AI agent step. \`value.tool_type\` is \`flowmodule\` for a +script or inline script the agent calls (or a nested agent or AI decision), \`mcp\` for an MCP server +(\`{ "tool_type": "mcp", "resource_path": "$res:<path>", "include_tools": [], "exclude_tools": [] }\`), +or \`websearch\` for web search (\`{ "tool_type": "websearch" }\`, summary \`Web Search\`). + +- Write logic made for this agent as an inline \`rawscript\` tool, as a flow writes its steps. Point a + \`script\` tool at a deployed script (\`path\`) only when one already does the job: a run calls the + deployed version, so a script that exists only as a draft fails the run. An inline tool's + \`value\` is a flow's rawscript step: + \`{ "tool_type": "flowmodule", "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { ... }", "input_transforms": { "query": { "type": "ai" } } }\` +- A flow cannot be a tool: a run offered one fails with \`Unsupported tool\`. Wrap it in a script + that runs the flow +- A flowmodule tool's \`summary\` is the name the agent calls it by: letters, numbers and underscores + only, unique among the agent's tools, and neither a reserved step id (a JavaScript keyword, \`ctx\`, + \`state\`, \`bg\`) nor \`__wm_web_search\`. Put the explanation in \`description\`, which tells the agent + when to call it +- Each argument of a tool is either the model's or fixed. \`{ "type": "ai" }\` (or no value) lets the + model fill it; a static value fixes it and hides it from the model. Fix what the model must not + choose, such as a resource, a table name or a limit + +## Using It From a Flow + +An AI agent step links to a saved agent with \`value.agent\` instead of carrying its own \`provider\`, +system prompt and tools: + +\`\`\`json +{ + "id": "support", + "value": { + "type": "aiagent", + "agent": "f/support/support_agent", + "input_transforms": { + "user_message": { "type": "javascript", "expr": "flow_input.question" } + }, + "tools": [] + } +} +\`\`\` + +The step holds only what belongs to one use of the agent: \`user_message\`, \`user_attachments\`, +\`enabled_tools\`, \`memory_id\`, \`previous_messages\`, and the \`model\` and \`reasoning_effort\` overrides. +Its \`value.tool_inputs\` (\`{ "<tool id>": { "<argument>": <input transform> } }\`) wires a saved +tool's arguments to this flow, e.g. \`flow_input.customer_id\`, over the transforms the agent saved, +without changing the agent. Everything else comes from the agent, so a change to the agent reaches +every flow linking it. `, "write-workflow-as-code": `--- name: write-workflow-as-code diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 976f82c382..2dc0b1fa59 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -14,7 +14,13 @@ import { untrack } from 'svelte' import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte' import { editUrlFor } from './sessions/forkEditUrl' - import { AppService, FlowService, ScriptService, type WorkspaceItemDiff } from '$lib/gen' + import { + AppService, + FlowService, + ResourceService, + ScriptService, + type WorkspaceItemDiff + } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { getDraftDiffValues, @@ -253,13 +259,45 @@ // side to diff the name against. Raw apps are fetched via the apps endpoint too // (it auto-detects raw from the deployed row and overlays the raw_app draft). const summaryCache = $state< - Record<string, { deployed?: string; draft?: string; stale?: boolean; loading?: boolean }> + Record< + string, + { + deployed?: string + draft?: string + stale?: boolean + loading?: boolean + /** A resource row that is an AI agent, and the path its draft names. */ + agent?: boolean + draftPath?: string + } + > >({}) async function fetchDraftSummary(item: Row) { if (summaryCache[item.key]) return summaryCache[item.key] = { loading: true } try { + // The drafts list says neither which resources are agents nor where a never-named one + // will deploy, so an agent row is read for its summary and its draft's path. + if (item.draftKind === 'resource') { + const r = await ResourceService.getResource({ + workspace: currentWorkspaceId, + path: item.path, + getDraft: true + }) + const draft = r.draft as { path?: string; description?: string; resource_type?: string } + const agent = (draft?.resource_type ?? r.resource_type) === 'ai_agent' + summaryCache[item.key] = agent + ? { + agent, + deployed: item.draft_only ? undefined : r.description, + draft: draft?.description ?? r.description, + draftPath: draft?.path, + loading: false + } + : { loading: false } + return + } const r = (await (item.draftKind === 'script' ? ScriptService.getScriptByPath({ workspace: currentWorkspaceId, @@ -296,8 +334,9 @@ for (const item of current) { if ( item.mine && - !item.draft_only && - (['script', 'flow', 'app'].includes(item.draftKind) || item.raw_app) && + ((!item.draft_only && + (['script', 'flow', 'app'].includes(item.draftKind) || item.raw_app)) || + item.draftKind === 'resource') && !summaryCache[item.key] ) { void fetchDraftSummary(item) @@ -611,6 +650,9 @@ ? `/pipeline/${encodeURIComponent(folder)}?workspace=${encodeURIComponent(currentWorkspaceId)}` : undefined } + if (summaryCache[d.key]?.agent) { + return `/agents/edit/${d.path}?workspace=${encodeURIComponent(currentWorkspaceId)}` + } const listPage = LIST_PAGE_FOR_KIND[d.draftKind] if (listPage) { return `${listPage}?workspace=${encodeURIComponent(currentWorkspaceId)}#${d.path}` @@ -668,6 +710,7 @@ {deploymentStatus} {allSelected} selectablePredicate={(item) => isDiscardable(item as unknown as Row)} + isAgent={(item) => !!summaryCache[item.key]?.agent} selectBlockedReason={(item) => blockedReason(item as unknown as Row)} onToggleItem={toggleItem} onSelectAll={selectAll} @@ -745,7 +788,7 @@ {@const oldSummary = cache?.deployed ?? draftItem.summary} {@const newSummary = cache?.draft ?? draftItem.summary} <WorkspaceDeployItemSummary - path={displayPath(draftItem)} + path={cache?.agent ? (cache.draftPath ?? draftItem.path) : displayPath(draftItem)} {editUrl} {oldSummary} {newSummary} @@ -758,7 +801,13 @@ {#snippet itemPath(item)} {@const draftItem = item as unknown as Row} - {#if draftItem.kind === 'resource' || draftItem.kind === 'variable' || draftItem.kind === 'resource_type'} + {@const agent = summaryCache[draftItem.key]?.agent + ? summaryCache[draftItem.key] + : undefined} + {#if agent} + <!-- Titled by its summary like a script; a summary-less one is titled by this path. --> + {#if agent.draft}{agent.draftPath ?? draftItem.path}{/if} + {:else if draftItem.kind === 'resource' || draftItem.kind === 'variable' || draftItem.kind === 'resource_type'} <!-- drawer-only items show their path as the title; keep this line empty --> {:else if !draftItem.draft_only && draftItem.draft_path && draftItem.draft_path !== draftItem.path} <!-- Path rename: a *deployed* item's draft moves it to a new path. Strike @@ -775,17 +824,21 @@ {#snippet itemActions(item)} {@const draftItem = item as unknown as Row} {@const others = otherDraftUsers(draftItem)} - <Badge color="gray" size="xs">{kindLabel(draftItem.draftKind)}</Badge> - <DraftBadge - is_draft={true} - draft_only={draftItem.draft_only} - draft_users={draftItem.draft_users ?? []} - {currentUsername} - workspace={currentWorkspaceId} - itemKind={draftItem.draftKind} - path={draftItem.path} - allowFork={false} - /> + <div class="flex items-center gap-1"> + <Badge color="gray" size="xs"> + {summaryCache[draftItem.key]?.agent ? 'agent' : kindLabel(draftItem.draftKind)} + </Badge> + <DraftBadge + is_draft={true} + draft_only={draftItem.draft_only} + draft_users={draftItem.draft_users ?? []} + {currentUsername} + workspace={currentWorkspaceId} + itemKind={draftItem.draftKind} + path={draftItem.path} + allowFork={false} + /> + </div> {#if draftItem.mine && others.length > 0} <Popover openOnHover debounceDelay={50}> {#snippet trigger()} diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 083437f8f7..8868813958 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -493,6 +493,9 @@ // Summary cache: stores summaries from both workspaces type SummaryCache = Record<string, { current?: string; parent?: string; loading?: boolean }> let summaryCache = $state<SummaryCache>({}) + // Resource rows that are AI agents: titled by their summary and drawn as agents, as a script is. + // Per side, since a path can be an agent on one side and another resource type on the other. + let agentKeys = $state<Record<string, { current: boolean; parent: boolean }>>({}) // What has already been asked for. Deliberately not `$state`: the fetchers below run inside an // effect, and a guard that reads reactive state makes that effect depend on what it writes — // which is a loop. A plain Set is also what tells "asked, and the answer was nothing" from @@ -512,6 +515,14 @@ return `${diff.kind}:${diff.path}` } + // Drawn as an agent by the side being deployed, or by the target's when the source deletes it. + function isAgentRow(diff: WorkspaceItemDiff): boolean { + const sides = agentKeys[getItemKey(diff)] + if (!sides) return false + const fromCurrent = mergeIntoParent ? diff.exists_in_fork : !diff.exists_in_source + return fromCurrent ? sides.current : sides.parent + } + /** The two things a row needs from an item — the name it shows and who it runs as — are fields * of the same object, so it is read once per item per workspace and awaited by both loops. * @@ -579,9 +590,9 @@ }) async function fetchSummaries(diffs: WorkspaceItemDiff[]) { - // Only fetch summaries for scripts, flows, and apps + // Only fetch summaries for scripts, flows, apps, and resources (for the agents among them) const itemsToFetch = diffs.filter((diff) => - ['script', 'flow', 'app', 'raw_app', 'folder'].includes(diff.kind) + ['script', 'flow', 'app', 'raw_app', 'folder', 'resource'].includes(diff.kind) ) // Read once, so every request in this pass names the pair the pass was started for. Named // the way the diff names the two sides: `exists_in_fork` is this workspace, `exists_in_source` @@ -599,6 +610,33 @@ summaryCache[key] = { loading: true } + if (diff.kind === 'resource') { + const read = (ws: string) => + ResourceService.getResource({ workspace: ws, path: diff.path }).catch((error) => { + console.error(`Failed to fetch resource ${diff.path} in ${ws}`, error) + return undefined + }) + const [current, parent] = await Promise.all([ + diff.exists_in_fork ? read(forkWs) : undefined, + diff.exists_in_source ? read(sourceWs) : undefined + ]) + if (!alive) return + const sides = { + current: current?.resource_type === 'ai_agent', + parent: parent?.resource_type === 'ai_agent' + } + const agent = sides.current || sides.parent + if (agent) agentKeys[key] = sides + summaryCache[key] = agent + ? { + current: sides.current ? current?.description : undefined, + parent: sides.parent ? parent?.description : undefined, + loading: false + } + : { loading: false } + continue + } + // Only the sides that have the item. An item that is only ahead does not exist in the // other workspace, so asking it there is a 404 by construction — a guaranteed-failing // request per one-sided row, and a console error for each. @@ -1325,6 +1363,7 @@ <div class="bg-surface-tertiary p-4 rounded-md border"> <WorkspaceDeployLayout items={deployableItems} + isAgent={(item) => isAgentRow(item.diff as WorkspaceItemDiff)} {selectedItems} {deploymentStatus} {allSelected} @@ -1649,10 +1688,10 @@ <!-- Point the edit link at the workspace the item actually lives in: a parent-only row (deleted/absent in the fork) would 404 if linked into the fork, so link it into the parent instead. --> - {@const editUrl = editUrlFor( - diff, - diff.exists_in_fork ? currentWorkspaceId : parentWorkspaceId - )} + {@const linkWs = diff.exists_in_fork ? currentWorkspaceId : parentWorkspaceId} + {@const editUrl = agentKeys[key]?.[diff.exists_in_fork ? 'current' : 'parent'] + ? `/agents/edit/${diff.path}?workspace=${encodeURIComponent(linkWs)}` + : editUrlFor(diff, linkWs)} {#if isTriggerOrScheduleKind(diff.kind)} <span class="text-emphasis"> {KIND_DISPLAY_NAMES[diff.kind as string] ?? diff.kind} diff --git a/frontend/src/lib/components/FlowMetadata.svelte b/frontend/src/lib/components/FlowMetadata.svelte index b28e694bc3..ccda612c35 100644 --- a/frontend/src/lib/components/FlowMetadata.svelte +++ b/frontend/src/lib/components/FlowMetadata.svelte @@ -2,6 +2,7 @@ import { type Job } from '$lib/gen' import JobStatus from '$lib/components/JobStatus.svelte' import { flowPathToHref } from '$lib/scripts' + import { agentRunHref } from '$lib/components/flows/conversations/agentEditorChat' import { displayDate, truncateRev } from '$lib/utils' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' import TimeAgo from './TimeAgo.svelte' @@ -17,6 +18,8 @@ } let { job, scheduleEditor }: Props = $props() + // An agent's run is a flow preview, linked to the agent it ran. + const agentHref = $derived(job ? agentRunHref(job, $workspaceStore) : undefined) </script> <div @@ -67,9 +70,7 @@ <Bot size={SMALL_ICON_SIZE} class="min-w-3.5" /> <span class="whitespace-nowrap"> Triggered by parent - <a href={`/run/${job.parent_job}?workspace=${$workspaceStore}`}> - {job.parent_job}</a - > + <a href={`/run/${job.parent_job}?workspace=${$workspaceStore}`}> {job.parent_job}</a> </span> </div> {/if} @@ -91,12 +92,12 @@ </div> {/if} - {#if (job && job.job_kind == 'flow') || job?.job_kind == 'script'} + {#if (job && job.job_kind == 'flow') || job?.job_kind == 'script' || agentHref} {@const stem = `${job?.job_kind}s`} {@const isScript = job?.job_kind === 'script'} {@const viewHref = isScript ? `/${stem}/get/${job?.script_hash}` - : flowPathToHref(job?.script_path ?? '')} + : (agentHref ?? flowPathToHref(job?.script_path ?? ''))} <div class="flex flex-row gap-2 items-center"> {#if isScript} <Code2 size={SMALL_ICON_SIZE} class="min-w-3.5" /> diff --git a/frontend/src/lib/components/WorkspaceDeployLayout.svelte b/frontend/src/lib/components/WorkspaceDeployLayout.svelte index de666451ee..62894ea508 100644 --- a/frontend/src/lib/components/WorkspaceDeployLayout.svelte +++ b/frontend/src/lib/components/WorkspaceDeployLayout.svelte @@ -57,6 +57,8 @@ itemPath?: Snippet<[DeployableItem]> itemActions?: Snippet<[DeployableItem]> footer?: Snippet + /** A resource row that is an AI agent, drawn with the agent's icon. */ + isAgent?: (item: DeployableItem) => boolean // Callbacks onToggleItem?: (item: DeployableItem) => void @@ -83,6 +85,7 @@ itemPath, itemActions, footer, + isAgent, onToggleItem, onSelectAll, onDeselectAll @@ -251,8 +254,12 @@ {@const isDeployed = status?.status === 'deployed'} {@const blockedReason = !isSelectable && !isDeployed ? selectBlockedReason?.(item) : undefined} + <!-- A resource is titled by its path, except an agent, titled by its summary. --> {@const showPath = - item.kind !== 'resource' && item.kind !== 'variable' && item.kind !== 'resource_type'} + isAgent?.(item) || + (item.kind !== 'resource' && + item.kind !== 'variable' && + item.kind !== 'resource_type')} <!-- The group header already names the folder — the path line only carries what's below it. --> {@const subPath = @@ -270,7 +277,7 @@ onSelect={() => handleSelect(item)} path={showPath ? item.path : ''} marked={undefined} - kind={item.kind} + kind={isAgent?.(item) ? 'agent' : item.kind} triggerKind={item.triggerKind} canFavorite={false} workspaceId="" diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 89a00c06bc..c490459d5e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -170,7 +170,8 @@ import { type ChatCommandItem, type SessionPromptContext, type GlobalToolHelpers, - type GlobalActivePreviewContext + type GlobalActivePreviewContext, + type GlobalActiveEditorContext } from './global/core' import { assembleGlobalSystemMessage, @@ -598,7 +599,7 @@ export class AIChatManager implements ChatViewHost { * control elsewhere. */ openRunOnDeployedPage?: (a: { toolCallId: string - kind: 'script' | 'flow' + kind: 'script' | 'flow' | 'agent' path: string summary: string carries: boolean @@ -946,6 +947,8 @@ export class AIChatManager implements ChatViewHost { // a page tab is an iframe in its own realm, so the tab model is the only place the // chat can learn it. Undefined for a live editor — ACTIVE EDITOR covers those. activePreviewResolver: (() => GlobalActivePreviewContext | undefined) | undefined = undefined + // An editor the draft registry does not cover, such as an agent's in a session tab. + activeEditorResolver: (() => GlobalActiveEditorContext | undefined) | undefined = undefined // Resolves the workspace this chat operates on. Session chats set it to their // own (possibly forked) workspace so the chat targets it WITHOUT switching the // global workspaceStore. Undefined for the global side-panel chat, which @@ -2234,7 +2237,7 @@ export class AIChatManager implements ChatViewHost { if (form.kind === 'run' && form.path && !form.conversational) { this.openRunOnDeployedPage?.({ toolCallId: toolId, - kind: form.runnableKind === 'flow' ? 'flow' : 'script', + kind: form.agentPage ? 'agent' : form.runnableKind === 'flow' ? 'flow' : 'script', path: form.path, summary: form.summary || form.path, carries: true, @@ -3422,6 +3425,7 @@ export class AIChatManager implements ChatViewHost { { workspace: this.operatingWorkspace, activePreview: this.activePreviewResolver?.(), + activeEditor: this.activeEditorResolver?.(), resourceTypeSync: this.resourceTypeSync } ) @@ -4268,6 +4272,7 @@ export class AIChatManager implements ChatViewHost { userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, { workspace: this.operatingWorkspace, activePreview: this.activePreviewResolver?.(), + activeEditor: this.activeEditorResolver?.(), images: sentImages, files: files, resourceTypeSync: this.resourceTypeSync diff --git a/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawersInner.svelte b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawersInner.svelte index 9dd8fe643c..ab0d1f0fce 100644 --- a/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawersInner.svelte +++ b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawersInner.svelte @@ -7,6 +7,8 @@ import { Loader2 } from 'lucide-svelte' import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte' import VariableEditor from '$lib/components/VariableEditor.svelte' + import { goto } from '$app/navigation' + import { agentEditHref } from './workspaceItems.svelte' import type { CreatedResourceTriggerKind, ToolDisplayAction } from './shared' type DrawerKey = 'schedule' | CreatedResourceTriggerKind @@ -122,6 +124,11 @@ return } + if (action.resource === 'agent') { + await goto(agentEditHref(action.path)) + return + } + if (action.resource === 'resource') { await resourceEditorDrawer?.initEdit(action.path) return diff --git a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte index 20e9b1df91..da7cb4de03 100644 --- a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte @@ -39,8 +39,9 @@ const draft = untrack(() => aiChatManager.runFormDraft(toolCallId, runForm)) const runnableKind = $derived(runForm.runnableKind ?? 'script') + const runnableNoun = $derived(runForm.cardNoun ?? runnableKind) const staleFormToast = () => - sendUserToast(`This run form is no longer active — ask again to run the ${runnableKind}.`, true) + sendUserToast(`This run form is no longer active — ask again to run the ${runnableNoun}.`, true) const properties = $derived(draft.schema?.properties ?? {}) const hasArgs = $derived(Object.keys(properties).length > 0) @@ -173,7 +174,7 @@ bind:args={draft.args} /> {:else} - <p class="text-xs text-secondary">This {runnableKind} takes no arguments.</p> + <p class="text-xs text-secondary">This {runnableNoun} takes no arguments.</p> {/if} </div> </div> @@ -210,7 +211,7 @@ {/if} {#if runForm.resetKeys?.length} <p class="text-2xs text-secondary"> - Disabled by this {runnableKind}, so it will run with its default: + Disabled by this {runnableNoun}, so it will run with its default: <span class="font-mono">{runForm.resetKeys.join(', ')}</span> </p> {/if} diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index b58ea838bf..410b6247c8 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -75,6 +75,7 @@ ? 'flow' : 'script') ) + const runnableNoun = $derived(runForm?.cardNoun ?? runnableKind) // Declining the form, stopping the turn and cancelling the job all land here, and none // of them is a failure: the run stopped because someone said so. const canceled = $derived( @@ -198,8 +199,8 @@ ) const cancelReason = $derived( ran - ? `This run was cancelled while the ${runnableKind} was running.` - : `This run was cancelled before the ${runnableKind} started.` + ? `This run was cancelled while the ${runnableNoun} was running.` + : `This run was cancelled before the ${runnableNoun} started.` ) // Streaming opens the tab early: the result is already arriving, and one that appeared // only at the end would hide the thing the user is waiting to read. @@ -449,13 +450,16 @@ // A conversational flow's page has no run form, so opening it is all it can offer: the // run stays on this card, where there is one to confirm. const carries = $derived(asDeployedPage && !runForm?.conversational) + const deployedNoun = $derived( + runForm?.agentPage ? 'agent' : runnableKind === 'flow' ? 'flow' : 'script' + ) const previewTitle = $derived( previewTarget === 'form' ? `Open this form in the preview panel: ${path}` : previewTarget === 'deployed' ? carries - ? `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'} and confirm this run there: ${path}` - : `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'}: ${path}` + ? `Open the deployed ${deployedNoun} and confirm this run there: ${path}` + : `Open the deployed ${deployedNoun}: ${path}` : aiChatManager.openRunInPreview ? `Open this run in the preview panel: ${path || runnableName}` : `Open this run in a new tab: ${path || runnableName}` @@ -470,7 +474,7 @@ if (previewTarget === 'deployed') { aiChatManager.openRunOnDeployedPage?.({ toolCallId: message.tool_call_id, - kind: runnableKind === 'flow' ? 'flow' : 'script', + kind: deployedNoun, path, summary: label, carries @@ -758,7 +762,7 @@ unifiedSize="sm" destructive startIcon={{ icon: TimerOff }} - title={`Cancel the ${runnableKind}`} + title={`Cancel the ${runnableNoun}`} onClick={() => aiChatManager.cancelJob(chatJob.jobId)} > Cancel diff --git a/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte b/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte index 84ee04d06f..fd7591d083 100644 --- a/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte @@ -2,6 +2,7 @@ import { Button } from '$lib/components/common' import { ArrowRight, + Bot, Boxes, Calendar, Database, @@ -38,7 +39,7 @@ actions: ToolDisplayAction[] } - type ActionCardKey = 'schedule' | 'resource' | 'variable' | CreatedResourceTriggerKind + type ActionCardKey = 'schedule' | 'resource' | 'agent' | 'variable' | CreatedResourceTriggerKind type ActionCardConfig = { title: string icon: any @@ -51,6 +52,7 @@ const actionCardConfigs: Record<ActionCardKey, ActionCardConfig> = { schedule: { title: 'Schedule', icon: Calendar }, resource: { title: 'Resource', icon: Package }, + agent: { title: 'Agent', icon: Bot }, variable: { title: 'Variable', icon: KeyRound }, http: { title: 'HTTP trigger', icon: Route }, websocket: { title: 'WebSocket trigger', icon: Unplug }, diff --git a/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte b/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte index d808003d14..7a851d8306 100644 --- a/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte @@ -7,7 +7,7 @@ import { openItemPreviewAction, type PreviewCardKind } from './shared' interface Props { - card: { kind: PreviewCardKind; path: string } + card: { kind: PreviewCardKind | 'agent'; path: string } /** Opens something other than the item's own preview — the run card opens the call * it owns, which is a form before it is a run. */ onOpen?: () => void @@ -34,7 +34,18 @@ } opening = true try { - await runToolDisplayAction(openItemPreviewAction(card.kind, card.path, mode)) + // An agent is not a live editor the panel hosts: its editor opens as a page tab. + await runToolDisplayAction( + card.kind === 'agent' + ? { + id: `open-agent:${card.path}`, + type: 'open_created_resource', + label: 'Open agent', + resource: 'agent', + path: card.path + } + : openItemPreviewAction(card.kind, card.path, mode) + ) } finally { opening = false } diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 17cd4c8960..ff686b6de7 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -515,6 +515,21 @@ describe('global AI tools', () => { expect(result).not.toContain('Python Datatable API') }) + it('gives agent guidance only to a session that may also draft flows', async () => { + const resourcesOnly = await callGlobalTool( + 'get_instructions', + { subject: 'agent' }, + toolCallbacks, + { access: new Set(['write_resource_draft']) } + ) + expect(resourcesOnly).not.toContain('Windmill agent authoring reference') + const builder = await callGlobalTool('get_instructions', { subject: 'agent' }, toolCallbacks, { + access: new Set(['write_resource_draft', 'write_flow_draft']) + }) + expect(builder).toContain('Windmill agent authoring reference') + expect(builder).toContain('This user cannot write inline code') + }) + it('returns only the requested language SDK for the datatable subject', async () => { const ts = await callGlobalTool('get_instructions', { subject: 'datatable', language: 'bun' }) expect(ts).toContain('TypeScript Datatable API') diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index c93c83eae4..50fde9e552 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -49,7 +49,18 @@ import type { import { loadSchemaFromModule, memoryPropertyFor } from '$lib/components/flows/flowInfers' import { linkedAgentStepSchema } from '$lib/components/flows/agentResourceUtils' import { AGENT_FIELDS, initialVisibleAgentFields } from '$lib/components/flows/agentFormFields' -import { withAgentDrafts } from '$lib/components/flows/linkedAgentDrafts' +import { + fetchAgentWithDraft, + inlineAgentDraft, + withAgentDrafts +} from '$lib/components/flows/linkedAgentDrafts' +import { + AGENT_CHAT_SCHEMA, + AGENT_STEP_ID, + agentChatFlow, + agentChatPath +} from '$lib/components/flows/conversations/agentEditorChat' +import type { AIAgentConfig } from '$lib/components/flows/agentResourceUtils' import { resolveLinkedAgentTools } from '$lib/components/flows/flowState' import { enabledToolNames, type AgentTool } from '$lib/components/flows/agentToolUtils' import { evalValue } from '$lib/components/flows/utils.svelte' @@ -139,6 +150,7 @@ import { getPipelinePrompt, getRawAppPrompt, getResourcePrompt, + getAgentPrompt, getScriptPrompt } from '$system_prompts' import type { @@ -183,6 +195,8 @@ import { artifactTools } from '../artifacts/artifactTools' import type { SessionArtifactsStore } from '../artifacts/artifactsState.svelte' import type { PathRunType, Runnable } from '$lib/components/apps/inputType' import { UserDraft } from '$lib/userDraft.svelte' +import { agentEditHref } from '../workspaceItems.svelte' +import { markAgentWritten } from '$lib/components/flows/agentEditorStore.svelte' import { emptySchema } from '$lib/utils' import type { Schema } from '$lib/common' import { inferArgs } from '$lib/infer' @@ -330,7 +344,8 @@ const INSTRUCTION_SUBJECTS = [ // `pipeline` likewise isn't an item type — a data pipeline is a set of // annotated scripts in a folder, so it gets authoring guidance, not a CRUD type. // `workspace_settings` is the settings document an admin reads and changes. -const INSTRUCTION_SUBJECTS_EXTRA = ['datatable', 'pipeline', 'workspace_settings'] as const +// `agent` is a resource of type `ai_agent`, with authoring guidance of its own. +const INSTRUCTION_SUBJECTS_EXTRA = ['agent', 'datatable', 'pipeline', 'workspace_settings'] as const export const ALL_INSTRUCTION_SUBJECTS = [ ...INSTRUCTION_SUBJECTS, ...INSTRUCTION_SUBJECTS_EXTRA @@ -349,7 +364,7 @@ const ACTIVE_GLOBAL_EDITOR_DRAFTS: readonly { ] export type GlobalActiveEditorContext = { - type: ActiveGlobalEditorType + type: ActiveGlobalEditorType | 'agent' path: string /** The key the draft is stored under, which `path` leaves behind on a rename. */ storagePath: string @@ -401,7 +416,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum) const getInstructionsSchema = z.object({ subject: instructionSubjectSchema.describe( - 'What to get authoring instructions for: a workspace item type (script, flow, resource, app), "pipeline" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or "datatable" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables, or "workspace_settings" for the keys and rules of the workspace settings (admins only). Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' + 'What to get authoring instructions for: a workspace item type (script, flow, resource, app), "agent" for a saved AI agent (an `ai_agent` resource, as the Agents page lists it), "pipeline" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or "datatable" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables, or "workspace_settings" for the keys and rules of the workspace settings (admins only). Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' ), language: scriptLangSchema .optional() @@ -1066,6 +1081,45 @@ const runFlowToolDef = createToolDef( { strict: false } ) +const agentRunArgsSchema = z + .object({ + user_message: z.string().describe('The message the agent answers.') + }) + .passthrough() + .describe("The run's inputs: `user_message`, and `user_attachments` for files the user attaches.") + +const testRunAgentSchema = z.object({ + path: z.string().describe('Workspace path of the agent (`ai_agent` resource) to test.'), + args: agentRunArgsSchema, + memory_id: testRunFlowSchema.shape.memory_id.describe( + 'A UUID naming the conversation this turn belongs to: reuse the same one across calls to test memory and follow-ups, and omit it for a one-off turn in a conversation of its own. Generate the UUID yourself so you can pass it again.' + ), + background: backgroundArgSchema, + wait_seconds: waitSecondsArgSchema +}) + +const testRunAgentToolDef = createToolDef( + testRunAgentSchema, + 'test_run_agent', + 'Execute a test run of an AI agent by path, preferring its draft when one exists, so an agent you drafted can be tried before the user deploys it. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.', + { strict: false } +) + +const runAgentSchema = z.object({ + path: z.string().describe('Workspace path of the deployed agent (`ai_agent` resource) to run.'), + args: agentRunArgsSchema, + memory_id: testRunAgentSchema.shape.memory_id, + background: backgroundArgSchema, + wait_seconds: waitSecondsArgSchema +}) + +const runAgentToolDef = createToolDef( + runAgentSchema, + 'run_agent', + 'Run a DEPLOYED AI agent for real, as its own page runs it. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. Use only when the user names the deployed version ("the deployed X", "in production", "for real").', + { strict: false } +) + const testRunStepSchema = z.object({ path: z.string().describe('Workspace path of the flow containing the step to test.'), stepId: z.string().describe('The id of the step/module to test.'), @@ -1236,9 +1290,9 @@ const testRunAppRunnableToolDef = createToolDef( const openPreviewSchema = z.object({ kind: z - .enum(['script', 'flow', 'raw_app', 'pipeline']) + .enum(['script', 'flow', 'raw_app', 'agent', 'pipeline']) .describe( - 'Item kind to preview. Use "raw_app" for code-based apps. Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' + 'Item kind to preview. Use "raw_app" for code-based apps and "agent" for a standalone AI agent (an `ai_agent` resource). Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' ), path: z .string() @@ -1256,9 +1310,9 @@ const openPreviewSchema = z.object({ * a workspace capability says nothing about `f/finance/*` — and is resolved in the * handler instead. Mirrors `open_page` re-narrowing its page enum per user. */ const openPreviewNoPipelineKind = z - .enum(['script', 'flow', 'raw_app']) + .enum(['script', 'flow', 'raw_app', 'agent']) .describe( - 'Item kind to preview. Use "raw_app" for code-based apps. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' + 'Item kind to preview. Use "raw_app" for code-based apps and "agent" for a standalone AI agent (an `ai_agent` resource). The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' ) const openPreviewViewOnlySchema = openPreviewSchema.extend({ @@ -1287,7 +1341,7 @@ const openPreviewBuilderEditorSchema = (flows: boolean, apps: boolean) => }) const OPEN_PREVIEW_DESCRIPTION = - 'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.' + 'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, raw app or AI agent to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.' const OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION = 'Show a workspace item in the side panel next to the chat, as its deployed page — the run form, triggers and past runs the user can act on. ONLY works inside an AI session. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). You cannot open item editors in this workspace, so use this to show the user something that is already deployed. Returns an error if there is no active session.' @@ -1725,11 +1779,14 @@ ${groupMembershipRule}- Listings and reads return only what this user can see: i - An agent, as the Agents page lists it, is a resource of type \`ai_agent\`: read it with read_workspace_item type "resource".${when( // An agent draft is a resource draft that the server checks under the flow builder right. canDraftResources && canWriteFlowDraft, - " You may draft a change to an agent, but never deploy or rename one here: the user reviews and deploys it from the agent's page, `/agents/edit/<path>`, so point them there." + ` You may draft a new agent or a change to one, after calling get_instructions with subject "agent". The user can review it on the agent's page, ${external ? 'the url write_resource returns' : '\`/agents/edit/<path>\`'}.` )}${when( buildsFlows, ' Its tools, like flow steps, may only reference deployed scripts and flows by path: the server refuses inline code and hub scripts.' )}${when( + canRunFlowPreview, + ' Test an agent, its draft included, with test_run_agent.' + )} Run a deployed agent with run_agent, never with run_flow or run_script.${when( !external, ` - If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".` @@ -1802,7 +1859,7 @@ ${when(canCreateResourceType, `- create_resource_type creates a resource type th ? `${when( !canWriteDraft, ` -- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. ${ +- open_preview(kind, path) shows a deployed script / flow / app / agent in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. ${ buildsFlows && buildsApps ? 'The flow and app editors are the only editors you can open here: after writing or editing a flow or app draft, show it with open_preview(kind="flow" or "raw_app", path, mode="edit").' : buildsFlows || buildsApps @@ -1812,7 +1869,7 @@ ${when(canCreateResourceType, `- create_resource_type creates a resource type th )}${when( canWriteDraft, ` -- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited. +- After writing or substantially editing a script / flow / app / agent draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited. - Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canCreateFolder, 'or not-yet-created ')}folder is fine${when(canCreateFolder, ' (create_folder first if needed, then open it)')}. Opening it adds build_pipeline_node / edit_pipeline_node to your tools from the next step on — they are not in your tool list before open_preview returns, so call open_preview on its own, wait for its result, then build. They then stay available for the rest of the session even while another tab (a page, another preview) is shown: a call brings the pipeline editor back by itself, so do not re-open it first. Use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.` )} - When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app"). @@ -2892,6 +2949,7 @@ function getResourceInstructions(): string { - 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). - 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. - Use \`search_resource_types\` to discover valid \`resource_type\` names and their JSON Schemas. Match the resource value to that schema. +- For an agent (an \`ai_agent\` resource), call \`get_instructions\` with \`subject: "agent"\` instead. - For OAuth resources, the \`is_oauth: true\` flag is managed by Windmill's OAuth flow; global mode generally creates manual resources, not OAuth ones. # Windmill resource & variable reference @@ -2899,6 +2957,38 @@ function getResourceInstructions(): string { ${getResourcePrompt()}` } +async function getAgentInstructions( + workspace: string | undefined, + external: boolean, + /** The user cannot write inline code: the server refuses a rawscript tool from them. */ + composeOnly: boolean, + /** The session cannot run `test_run_agent`. */ + untestable: boolean +): Promise<string> { + const aiAgentProviders = external + ? '' + : formatAiAgentProvidersPrompt(await getAiAgentProviderCatalog(workspace), { + canAskUser: true + }) + const testRule = untestable + ? '' + : '\n- Test the draft with `test_run_agent` before handing it over, and fix what the run shows: a test runs the draft as it stands, its tools included. Reuse one `memory_id` across calls to test follow-ups.' + return `# Global draft agent instructions + +- An agent is drafted with \`write_resource\`: \`{ path, resource_type: "ai_agent", description?, value }\`, where \`value\` is the agent's configuration described below. To change an existing agent, read it with \`read_workspace_item\` type "resource" first and write back the whole value. +- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` for a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist.${testRule} +- The user can review the agent on its page, ${external ? 'the url `write_resource` returns' : '`/agents/edit/<path>`'}. Deploy it with \`deploy_workspace_item\` (type "resource") once they ask, as for any other draft. +${ + composeOnly + ? '- This user cannot write inline code, so skip what the reference says about inline tools: every script tool points at a deployed script, found with the workspace search tools. When none does the job, say so and suggest asking a workspace admin for that script; never add a rawscript tool.' + : '- Before writing an inline tool, look for a deployed script that already does the job with the workspace search tools.' +} +${aiAgentProviders ? `\n${aiAgentProviders}\n` : ''} +# Windmill agent authoring reference + +${getAgentPrompt()}` +} + function getDatatableInstructions(language?: ScriptLang): string { // Default to the TypeScript SDK so we return only what's needed, not both. const lang = language ?? 'bun' @@ -2923,7 +3013,9 @@ export function getInstructions( language: ScriptLang | undefined, workspace: string | undefined, composeOnly: boolean, - external = false + external = false, + /** The session cannot run a flow preview, which an agent's test run is. */ + untestable = false ): string | Promise<string> { switch (subject) { case 'script': @@ -2932,6 +3024,8 @@ export function getInstructions( return getFlowInstructions(workspace, composeOnly, external) case 'resource': return getResourceInstructions() + case 'agent': + return getAgentInstructions(workspace, external, composeOnly, untestable) case 'app': return getAppInstructions(language, composeOnly, external) case 'datatable': @@ -3603,7 +3697,7 @@ function buildOpenPageDefSchema( } const OPEN_PAGE_DESCRIPTION = - 'Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. "show me the failed runs of X", "open the schedule for Y", "open the git sync settings", "open the kafka triggers"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource\'s credentials, set a variable\'s value — pass open with the item path so its editor opens directly). Never offer page "compare" for draft review. Use it only when the user explicitly asks to deploy a forked workspace into its parent; it always opens the fork-vs-parent comparison. This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.' + 'Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. "show me the failed runs of X", "open the schedule for Y", "open the git sync settings", "open the kafka triggers"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource\'s credentials, set a variable\'s value — pass open with the item path so its editor opens directly). Never offer page "compare" for draft review. Use it only when the user explicitly asks to deploy a forked workspace into its parent; it always opens the fork-vs-parent comparison. This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, agents, pipelines). Only pages listed for this user are available; do not offer others.' // Non-arg input the URL builder needs: the chat's operating workspace (the compare // page cannot fall back to its own store default inside a session preview). @@ -3887,14 +3981,37 @@ export const openPageTool: SessionTool<{}> = { return previewResult } + // A session's tab opens an agent in its own editor; the resources page would show it as JSON. + const agentPath = + page === 'resources' && parsed.open + ? await ResourceService.getResource({ + workspace: urlWorkspace, + path: parsed.open, + getDraft: true + }) + .then((r) => (r.resource_type === 'ai_agent' ? parsed.open : undefined)) + .catch(() => undefined) + : undefined + const linkUrl = agentPath ? agentEditHref(agentPath) : url + const linkLabel = agentPath ? 'Agent' : pageLabel + const linkSummary = agentPath ?? summary + // Outside a session there is no preview panel — offer a clickable link the user // controls. (We deliberately don't navigate in place: a same-route goto would // not re-sync the page's URL-driven filter state, so the change wouldn't land.) toolCallbacks.setToolStatus(toolId, { - content: `Prepared a link to ${pageLabel}: ${summary}`, - actions: [{ id: `open-page:${page}:${url}`, type: 'navigate', label: summary, url, page }] + content: `Prepared a link to ${linkLabel}: ${linkSummary}`, + actions: [ + { + id: `open-page:${page}:${linkUrl}`, + type: 'navigate', + label: linkSummary, + url: linkUrl, + page + } + ] }) - return `Offered the user a link to the ${pageLabel} page (${summary}). They can click it to open.` + return `Offered the user a link to the ${linkLabel} page (${linkSummary}). They can click it to open.` } } @@ -3906,7 +4023,7 @@ export const globalTools: SessionTool<{}>[] = [ def: createToolDef( getInstructionsSchema, 'get_instructions', - 'Get authoring guidance for scripts, flows, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.' + 'Get authoring guidance for scripts, flows, AI agents, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.' ), planModeSafe: true, fn: async (ctx) => { @@ -3924,7 +4041,11 @@ export const globalTools: SessionTool<{}>[] = [ access.has('write_draft') || (parsed.subject === 'flow' && access.has('write_flow_draft')) || (parsed.subject === 'app' && access.has('write_app_draft')) || - (parsed.subject === 'resource' && access.has('write_resource_draft')) + (parsed.subject === 'resource' && access.has('write_resource_draft')) || + // The server checks an agent write under the flow builder right too. + (parsed.subject === 'agent' && + access.has('write_resource_draft') && + access.has('write_flow_draft')) if (!canAuthor) { const message = 'This session cannot create or edit this kind of item, so there is no authoring guidance to give for it. Tell the user plainly rather than describing how it would be done.' @@ -3937,17 +4058,25 @@ export const globalTools: SessionTool<{}>[] = [ : parsed.subject toolCallbacks.setToolStatus(toolId, { content: `Loaded ${label} instructions` }) // Guidance for inline code would steer a composing role at writes the server refuses - // and tools it was not given. + // and tools it was not given. An agent's test run is a flow preview, so its inline + // tools follow the flow rule. const composeOnly = !!access && !access.has( - parsed.subject === 'flow' + parsed.subject === 'flow' || parsed.subject === 'agent' ? 'write_inline_flow_code' : parsed.subject === 'app' ? 'write_inline_app_code' : 'write_draft' ) - return getInstructions(parsed.subject, parsed.language, ctx.workspace, composeOnly) + return getInstructions( + parsed.subject, + parsed.language, + ctx.workspace, + composeOnly, + false, + !!access && !access.has('run_flow_preview') + ) } }, createSearchHubScriptsTool(false), @@ -4528,6 +4657,36 @@ export const globalTools: SessionTool<{}>[] = [ showDetails: true, autoCollapseDetails: false }, + { + requires: RUN_FLOW_PREVIEW, + def: testRunAgentToolDef, + fn: async (ctx) => { + const parsed = testRunAgentSchema.parse(ctx.args) + return testRunAgentByPath(parsed, ctx) + }, + // No requiresConfirmation, for the reason test_run_script carries. + bypassedByAutoAccept: true, + streamingLabel: 'Preparing the test form...', + confirmationMessage: 'Run a test of an agent', + queuedLabel: (args) => `Test ${args?.path ?? 'the agent'}`, + showDetails: true, + autoCollapseDetails: false + }, + { + requires: NONE, + def: runAgentToolDef, + readOnlySafe: true, + fn: async (ctx) => { + const parsed = runAgentSchema.parse(ctx.args) + return runDeployedAgent(parsed, ctx) + }, + bypassedByAutoAccept: true, + streamingLabel: 'Preparing the run form...', + confirmationMessage: 'Run a deployed agent', + queuedLabel: (args) => `Run ${args?.path ?? 'an agent'}`, + showDetails: true, + autoCollapseDetails: false + }, { requires: RUN_PREVIEW, def: testRunStepToolDef, @@ -5430,7 +5589,7 @@ function liveFlowTestHookFromCtx( export type OpenPreviewHandler = (req: { sessionId: string | undefined - kind: 'script' | 'flow' | 'raw_app' | 'pipeline' + kind: 'script' | 'flow' | 'raw_app' | 'agent' | 'pipeline' path: string /** Which side to open. Omitted means the editor, except for a pipeline, which has only * one side. `openSessionPreview` resolves it before its capability gates rather than @@ -5446,7 +5605,11 @@ export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined): } async function openSessionPreview( - args: { kind: 'script' | 'flow' | 'raw_app' | 'pipeline'; path: string; mode?: 'edit' | 'view' }, + args: { + kind: 'script' | 'flow' | 'raw_app' | 'agent' | 'pipeline' + path: string + mode?: 'edit' | 'view' + }, sessionId: string | undefined, workspace: string | undefined, access: SessionAccess | undefined @@ -5461,6 +5624,18 @@ async function openSessionPreview( } return await openPreviewHandler({ ...args, mode: undefined, sessionId }) } + // An agent's editor drafts its resource, which the server checks under the flow builder right + // too; without both its deployed page is what the session can show. The editor itself turns + // read-only on an agent the user cannot write. + if (args.kind === 'agent') { + const canDraft = + !access || (access.has('write_resource_draft') && access.has('write_flow_draft')) + return await openPreviewHandler({ + ...args, + mode: args.mode === 'view' || !canDraft ? 'view' : 'edit', + sessionId + }) + } // Resolved before the gates, not after: the handler reads a missing mode as the editor, so // gating on `args.mode === 'edit'` would wave through every call that simply omitted it — // which is what a model whose schema only offers 'view' does. @@ -5968,15 +6143,15 @@ const PREVIEW_CARD_KIND_BY_ITEM_KIND: Partial< // `open_preview` takes), not its synthetic draft storage key. function maybeAttachPreviewCard( ctx: WriteDraftCtx, - itemKind: DraftPersistResult['itemKind'], + kind: PreviewCardKind | 'agent' | undefined, path: string -): void { +): boolean { // Write tools pass the raw tool ctx, whose session id lives in `helpers` — // `ctx.sessionId` is only set by callers that thread it explicitly. - if (!ctx.sessionId && !sessionIdFromCtx(ctx)) return - const kind = PREVIEW_CARD_KIND_BY_ITEM_KIND[itemKind] - if (!kind) return + if (!ctx.sessionId && !sessionIdFromCtx(ctx)) return false + if (!kind) return false ctx.toolCallbacks.setToolStatus(ctx.toolId, { previewCard: { kind, path } }) + return true } // App write tools build varied success messages but share the same conflict / @@ -5991,7 +6166,7 @@ function finishAppDraftWrite( if (failure) return failure if (codeDiff) ctx.toolCallbacks.setToolStatus(ctx.toolId, { codeDiff }) ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath) - maybeAttachPreviewCard(ctx, result.itemKind, result.item.path) + maybeAttachPreviewCard(ctx, PREVIEW_CARD_KIND_BY_ITEM_KIND[result.itemKind], result.item.path) const { content, message, warning } = onSaved() ctx.toolCallbacks.setToolStatus(ctx.toolId, { content, result: 'Saved as draft' }) return JSON.stringify({ success: true, message, warning }, null, 2) @@ -6001,14 +6176,24 @@ function finishDraftWrite( result: DraftPersistResult, existed: boolean, ctx: WriteDraftCtx, - codeDiff?: ToolCodeDiff + codeDiff?: ToolCodeDiff, + actions?: ToolDisplayAction[] ): string { const failure = draftWriteFailure(result, ctx) if (failure) return failure if (codeDiff) ctx.toolCallbacks.setToolStatus(ctx.toolId, { codeDiff }) ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath) - maybeAttachPreviewCard(ctx, result.itemKind, result.item.path) const stored = result.item + const isAgent = + stored.type === 'resource' && + (stored.value as CreateResource | undefined)?.resource_type === 'ai_agent' + const card = maybeAttachPreviewCard( + ctx, + isAgent ? 'agent' : PREVIEW_CARD_KIND_BY_ITEM_KIND[result.itemKind], + stored.path + ) + // Outside a session there is no preview chip, so the actions carry the way in. + if (actions && !card) ctx.toolCallbacks.setToolStatus(ctx.toolId, { actions }) const verb = existed ? 'Updated' : 'Created' // Don't echo the flow value back: the model just sent it in the write call, // so reflecting the (large) compact flow JSON only burns tokens. Variables @@ -6021,7 +6206,7 @@ function finishDraftWrite( : stored ctx.toolCallbacks.setToolStatus(ctx.toolId, { - content: `${verb} ${stored.type} "${stored.path}" as a draft`, + content: `${verb} ${isAgent ? 'agent' : stored.type} "${stored.path}" as a draft`, result: `Saved as draft` }) return JSON.stringify( @@ -6055,6 +6240,7 @@ async function writeDraft<T, A>( triggerKind?: TriggerKind override?: boolean codeDiff?: ToolCodeDiff | ((base: T | undefined, draft: T) => ToolCodeDiff | undefined) + actions?: ToolDisplayAction[] } = {} ): Promise<string> { const { workspace } = ctx @@ -6075,7 +6261,7 @@ async function writeDraft<T, A>( force: opts.override }) const codeDiff = typeof opts.codeDiff === 'function' ? opts.codeDiff(base, draft) : opts.codeDiff - return finishDraftWrite(result, existed, ctx, codeDiff) + return finishDraftWrite(result, existed, ctx, codeDiff, opts.actions) } type ScriptDraftArgs = { @@ -6283,7 +6469,11 @@ function writeResourceDraft( args: CreateResource & { override?: boolean }, ctx: WriteDraftCtx ): Promise<string> { - return writeDraft(RESOURCE_SPEC, 'resource', args.path, args, ctx, { override: args.override }) + return writeDraft(RESOURCE_SPEC, 'resource', args.path, args, ctx, { + override: args.override, + // The agent editor is where the user reviews and deploys the draft. + actions: args.resource_type === 'ai_agent' ? [createOpenAgentAction(args.path)] : undefined + }) } const VARIABLE_SPEC: WriteSpec<VariableDraftState, WriteVariableArgs> = { @@ -6790,6 +6980,10 @@ type FormRunSpec = { autoAcceptable?: boolean /** Whether this item's deployed page is a conversation rather than a run form. */ conversational?: boolean + /** The item is an agent, whose deployed page is the agent's rather than a flow's. */ + agentPage?: boolean + /** What the card's prose calls the runnable, when `contextName` would name it wrongly. */ + cardNoun?: string background?: boolean detachAfterMs?: number startJob: (submitted: Record<string, any>) => Promise<string> @@ -6885,6 +7079,8 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st kind: spec.kind, runnableKind: spec.contextName, conversational: spec.conversational || undefined, + agentPage: spec.agentPage || undefined, + cardNoun: spec.cardNoun, schema: autoAccepted ? undefined : schema, code: autoAccepted ? undefined : spec.code, lang: autoAccepted ? undefined : spec.lang, @@ -7193,6 +7389,109 @@ async function testRunFlowByPath( ) } +/** The agent at `path` as a run of it would use it: the user's draft when there is one, else the + * deployed resource. Refuses a path holding some other kind of resource. */ +async function loadAgentForTest( + path: string, + workspace: string +): Promise<{ config: AIAgentConfig; description?: string }> { + // Settles a save the agent editor still has pending, or refuses: a test of the stored row would + // otherwise run an older configuration than the one on screen. + const { response, draft } = await fetchAgentWithDraft(path, workspace) + if (response.resource_type !== 'ai_agent') { + throw new Error(`"${path}" is a ${response.resource_type} resource, not an AI agent.`) + } + return draft + ? { config: draft.args, description: draft.description } + : { config: response.value as AIAgentConfig, description: response.description } +} + +async function testRunAgentByPath( + args: z.infer<typeof testRunAgentSchema>, + ctx: WriteDraftCtx +): Promise<string> { + const { workspace } = ctx + const { config, description } = await loadAgentForTest(args.path, workspace) + // Who the agent runs as and who may ask it in Slack, not inputs: a test runs as its editor. + const { + on_behalf_of: _obo, + run_on_behalf_of: _runObo, + slack_anyone: _slack, + ...brain + } = config as AIAgentConfig & Record<string, unknown> + // The run the agent editor's chat makes of its draft. + const flow = agentChatFlow({ + id: AGENT_STEP_ID, + value: inlineAgentDraft({ type: 'aiagent', tools: [], input_transforms: {} } as any, brain) + } as FlowModule) + return runThroughForm( + { + path: args.path, + schema: AGENT_CHAT_SCHEMA, + summary: description, + kind: 'test', + schemaNoun: 'agent', + toolName: 'test_run_agent', + proposed: args.args, + startMessage: `Starting agent test run for "${args.path}"...`, + contextName: 'flow', + noun: 'agent', + cardNoun: 'agent', + autoAcceptable: true, + background: args.background, + detachAfterMs: waitSecondsToDetachMs(args.wait_seconds), + startJob: (submitted) => + JobService.runFlowPreview({ + workspace, + memoryId: args.memory_id ?? randomUUID(), + requestBody: { path: agentChatPath(args.path), value: flow.value, args: submitted } + }) + }, + ctx + ) +} + +async function runDeployedAgent( + args: z.infer<typeof runAgentSchema>, + ctx: WriteDraftCtx +): Promise<string> { + const { workspace } = ctx + const agent = await ResourceService.getResource({ workspace, path: args.path }) + if (agent.resource_type !== 'ai_agent') { + throw new Error(`"${args.path}" is a ${agent.resource_type} resource, not an AI agent.`) + } + return runThroughForm( + { + path: args.path, + schema: AGENT_CHAT_SCHEMA, + summary: agent.description, + kind: 'run', + // Its page is the agent's rather than a flow's, which is where the form moves. A turn of a + // conversation stays on the card: the page's run starts one of its own, without the memory id. + agentPage: true, + conversational: args.memory_id !== undefined, + schemaNoun: 'agent', + toolName: 'run_agent', + proposed: args.args, + startMessage: `Running agent "${args.path}"...`, + contextName: 'flow', + noun: 'agent', + cardNoun: 'agent', + autoAcceptable: true, + background: args.background, + detachAfterMs: waitSecondsToDetachMs(args.wait_seconds), + startJob: (submitted) => + JobService.runAgent({ + workspace, + path: args.path, + memoryId: args.memory_id, + requestBody: submitted as { user_message?: string } + }) + }, + ctx + ) +} + async function testRunFlowStepByPath( args: z.infer<typeof testRunStepSchema>, ctx: WriteDraftCtx @@ -7989,6 +8288,16 @@ function createOpenResourceAction(path: string): ToolDisplayAction { } } +function createOpenAgentAction(path: string): ToolDisplayAction { + return { + id: `open-agent:${path}`, + type: 'open_created_resource', + label: 'Open agent', + resource: 'agent', + path + } +} + function createOpenVariableAction(path: string): ToolDisplayAction { return { id: `open-deployed-variable:${path}`, @@ -9216,12 +9525,14 @@ async function deployDraft( } case 'resource': { let requestBody = draft.value as any + let isAgent = requestBody.resource_type === 'ai_agent' if (await ResourceService.existsResource({ workspace, path })) { // The server resets an agent's identity to whoever deploys, unless told to keep // it: the identity is the deployed one, never the draft's. const deployed = await ResourceService.getResource({ workspace, path }) const obo = (deployed.value as any)?.on_behalf_of - if (deployed.resource_type === 'ai_agent' && obo) { + isAgent = deployed.resource_type === 'ai_agent' + if (isAgent && obo) { requestBody = { ...requestBody, value: { ...requestBody.value, on_behalf_of: obo }, @@ -9232,7 +9543,8 @@ async function deployDraft( } else { await ResourceService.createResource({ workspace, requestBody }) } - actions = [createOpenResourceAction(path)] + if (isAgent) markAgentWritten(workspace, path) + actions = [isAgent ? createOpenAgentAction(path) : createOpenResourceAction(path)] break } case 'variable': { diff --git a/frontend/src/lib/components/copilot/chat/global/mcpSessionGen.test.ts b/frontend/src/lib/components/copilot/chat/global/mcpSessionGen.test.ts index 3de18622a5..25d48bc35f 100644 --- a/frontend/src/lib/components/copilot/chat/global/mcpSessionGen.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/mcpSessionGen.test.ts @@ -54,6 +54,16 @@ const DESCRIPTION_REWRITES: [tool: string, from: string, to: string][] = [ " Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the flow's own deployed page instead; either way this call waits for them and runs what they confirm.", ' It runs with exactly the `args` you send.' ], + [ + 'test_run_agent', + ' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.', + ' It runs with exactly the `args` you send.' + ], + [ + 'run_agent', + ' Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs.', + ' It runs with exactly the `args` you send.' + ], ['create_resource_type', ' once the user confirms the schema', ''], ['set_workspace_settings', ' The user reviews the changes before they apply.', ''] ] diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 13623dc73e..ef1c9077a6 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -522,7 +522,8 @@ export type CreatedResourceAction = { id: string type: 'open_created_resource' label: string - resource: 'schedule' | 'trigger' | 'resource' | 'variable' + /** `agent` is an `ai_agent` resource, opened on its own editor rather than as a resource. */ + resource: 'schedule' | 'trigger' | 'resource' | 'agent' | 'variable' path: string targetKind?: 'script' | 'flow' triggerKind?: CreatedResourceTriggerKind @@ -576,6 +577,11 @@ export type RunFormDisplay = { * flow. Its page can be opened to look at, but it has no form to confirm this call on, so * the card keeps the run. */ conversational?: boolean + /** What runs is a deployed agent, whose own page takes the form over rather than a script's + * or a flow's. */ + agentPage?: boolean + /** What the card's prose calls the runnable when `runnableKind` would name it wrongly. */ + cardNoun?: string /** Of whatever version is about to run: the deployed script, or the draft a test * previews. Only the rendered form reads it, so it is dropped once one of the flags * below unmounts that form: kept, every settled card would carry a copy of the schema @@ -698,7 +704,7 @@ export type ToolDisplayMessage = { /** Workspace item this tool created or updated. Rendered as a discrete, * always-visible card that opens (or focuses) the item's preview in the * session side panel. Set only for session chats — the side panel is their surface. */ - previewCard?: { kind: PreviewCardKind; path: string } + previewCard?: { kind: PreviewCardKind | 'agent'; path: string } /** Whole text on both sides of an edit this call saved. Kept in full rather than as a * reference: drafts are overwritten in place, so the sides cannot be fetched back later. */ codeDiff?: ToolCodeDiff diff --git a/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts b/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts index 787083282f..0cf9a90b27 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts @@ -31,6 +31,8 @@ export type WindmillItemKind = | 'app' | 'variable' | 'resource' + /** An `ai_agent` resource, which has its own pages. */ + | 'agent' | 'schedule' | 'http_trigger' | 'websocket_trigger' @@ -90,7 +92,10 @@ function workspaceItemTriggerKind(kind: WindmillItemKind): CreatedResourceAction */ export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string { const raw = - (entry.draftOnly && draftEditHref(entry)) || offboardingItemHref(entry.kind, entry.path) || '#' + (entry.draftOnly && draftEditHref(entry)) || + (entry.kind === 'agent' ? agentEditHref(entry.path) : undefined) || + offboardingItemHref(entry.kind, entry.path) || + '#' if (!workspace) return raw const hashIdx = raw.indexOf('#') if (hashIdx === -1) { @@ -102,6 +107,11 @@ export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string return `${pathPart}${sep}workspace=${workspace}${hashPart}` } +/** The agent editor, which shows the user's draft of the agent when there is one. */ +export function agentEditHref(path: string): string { + return `/agents/edit/${path}` +} + function draftEditHref(entry: WorkspaceItemEntry): string | undefined { switch (entry.kind) { case 'script': @@ -121,6 +131,7 @@ type WorkspaceItemListResult = Array<{ raw_app?: boolean | null draft_only?: boolean | null draft_path?: string | null + resource_type?: string }> const workspaceItemLoaders: Array<{ @@ -193,7 +204,7 @@ class WorkspaceItemRegistry { for (const it of items) { if (!map.has(it.path)) { map.set(it.path, { - kind, + kind: kind === 'resource' && it.resource_type === 'ai_agent' ? 'agent' : kind, path: it.path, targetKind: typeof it.is_flow === 'boolean' ? (it.is_flow ? 'flow' : 'script') : undefined, @@ -292,7 +303,7 @@ export function workspaceItemAction( path } - if (kind === 'resource' || kind === 'variable') { + if (kind === 'resource' || kind === 'agent' || kind === 'variable') { return { ...base, resource: kind } } diff --git a/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts b/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts index 764177bff5..dd4fa4a1b3 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts @@ -27,7 +27,7 @@ vi.mock('$lib/gen', () => { } }) -import { FlowService } from '$lib/gen' +import { FlowService, ResourceService } from '$lib/gen' import { extractCandidatePaths, itemHref, @@ -52,6 +52,18 @@ describe('workspaceItemRegistry', () => { expect(entry).toMatchObject({ kind: 'flow', path: 'u/me/draft_1234', draftOnly: true }) expect(itemHref(entry!, 'ws-draft')).toBe('/flows/edit/u/me/draft_1234?workspace=ws-draft') }) + + it('links an AI agent to its own editor, not to the resources page', async () => { + vi.mocked(ResourceService.listResource).mockResolvedValueOnce([ + { path: 'u/me/helper', resource_type: 'ai_agent' }, + { path: 'u/me/db', resource_type: 'postgresql' } + ] as any) + await workspaceItemRegistry.ensureLoaded('ws-agent') + const agent = workspaceItemRegistry.resolve('ws-agent', 'u/me/helper')! + expect(itemHref(agent, 'ws-agent')).toBe('/agents/edit/u/me/helper?workspace=ws-agent') + expect(workspaceItemAction(agent.kind, agent.path)).toMatchObject({ resource: 'agent' }) + expect(workspaceItemRegistry.resolve('ws-agent', 'u/me/db')?.kind).toBe('resource') + }) }) describe('WINDMILL_PATH_REGEX', () => { diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index c8f7ffc0d7..60157bcfb9 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -115,12 +115,18 @@ // The session's preview opens on this same deployed page (`/get/`), not the item's editor: // someone here came to run the item, not to change it. + const sessionPage = $derived( + !path + ? undefined + : errorHandlerKind + ? `/${errorHandlerKind}s/get/${path}` + : itemKind === 'agent' + ? `/agents/get/${path}` + : undefined + ) const sessionSource: OpenInSessionSource | undefined = $derived( - errorHandlerKind && path - ? { - page: () => `/${errorHandlerKind}s/get/${path}`, - workspaceId: $operatingWorkspace ?? undefined - } + sessionPage + ? { page: () => sessionPage, workspaceId: $operatingWorkspace ?? undefined } : undefined ) diff --git a/frontend/src/lib/components/flows/AgentHistory.svelte b/frontend/src/lib/components/flows/AgentHistory.svelte index c6deff1e59..8fc627b1ff 100644 --- a/frontend/src/lib/components/flows/AgentHistory.svelte +++ b/frontend/src/lib/components/flows/AgentHistory.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { Drawer, DrawerContent } from '$lib/components/common' import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte' - import { workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { markAgentWritten } from './agentEditorStore.svelte' interface Props { @@ -12,6 +12,9 @@ let { path, canRestore, onRestored }: Props = $props() let drawer: Drawer | undefined = $state() + // The workspace the history reads and restores in, which a session's fork makes other than the + // navigation one. + const operatingWorkspace = useOperatingWorkspace() export function open() { drawer?.openDrawer() @@ -26,7 +29,7 @@ onRestore={() => { drawer?.closeDrawer() // A restore writes the resource as a deploy does, so whatever shows this agent re-reads it. - markAgentWritten($workspaceStore ?? undefined, path) + markAgentWritten($operatingWorkspace ?? undefined, path) onRestored?.() }} /> diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index 5412c8728f..b5f5391810 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -431,6 +431,15 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { seedNewUntilInput = { ws, path } return } + // A view of what is deployed finding nothing there is that view's ordinary state for a + // draft-only agent, not a failure: it says so rather than refusing with a toast. + if (opts.deployedOnly?.() && (err as { status?: number })?.status === 404) { + loading = false + state = undefined + deployed = undefined + noDeployed = true + return + } // A failed load knows neither the resource's type nor its value, so it refuses: // clearing `loading` alone would let the sync restore a persisted draft into a form // that would then deploy over a resource nobody read. diff --git a/frontend/src/lib/components/flows/content/AgentDetailPage.svelte b/frontend/src/lib/components/flows/content/AgentDetailPage.svelte new file mode 100644 index 0000000000..8845311b42 --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentDetailPage.svelte @@ -0,0 +1,436 @@ +<script lang="ts"> + import { untrack } from 'svelte' + import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte' + import { Pane, Splitpanes } from 'svelte-splitpanes' + import { resource } from 'runed' + import { goto } from '$lib/navigation' + import { copilotInfo } from '$lib/aiStore' + import AgentEvalsModal from '$lib/components/flows/content/AgentEvalsModal.svelte' + import AgentUsePanel from '$lib/components/flows/content/AgentUsePanel.svelte' + import { SlackAgentsService } from '$lib/gen' + import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte' + import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte' + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import ShareModal from '$lib/components/ShareModal.svelte' + import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' + import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte' + import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte' + import RunForm from '$lib/components/RunForm.svelte' + import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte' + import { Alert, Tab, Tabs } from '$lib/components/common' + import { keepsManagedMemory } from '$lib/components/flows/agentFormFields' + import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions' + import AgentHistory from '$lib/components/flows/AgentHistory.svelte' + import { getDeployUiSettings } from '$lib/components/home/deploy_ui' + import { recentItems } from '$lib/components/home/recentItems.svelte' + import { useEditRights } from '$lib/operatorWriteRights' + import { sendUserToast } from '$lib/toast' + import { PLAN_MODE_MESSAGES } from '$lib/components/copilot/chat/planModeMessages' + import type { PendingRun } from '$lib/components/details/pendingRun' + import { + setOperatingWorkspace, + useOperatingUser + } from '$lib/components/operatingWorkspace.svelte' + + /** + * The deployed agent, as a flow's or a script's page shows theirs: a way to run it, a chat when + * its memory keeps the conversation and the inputs form otherwise, beside what it is. + */ + interface Props { + path: string + workspace: string | undefined + /** Rendered inside another page (a session tab) rather than as the route: the header stays + * in place instead of taking the page header, and every link goes through `onNavigate`. */ + embedded?: boolean + /** Whether this page is the one on screen, so its keyboard shortcut is not answered by a + * hidden tab. */ + active?: boolean + onNavigate?: (url: string) => void | Promise<void> + /** A chat call waiting on a run of this agent: the form opens on what it proposed, and the + * run started here answers it. */ + pendingRun?: PendingRun + /** Whether the agent is deployed, once that is known. */ + onLoadState?: (state: 'loaded' | 'not_found') => void + /** The deployed agent's summary, for a host that names the page after it. */ + onSummary?: (summary: string | undefined) => void + } + + let { + path, + workspace: ws, + embedded = false, + active = true, + onNavigate = (url) => goto(url), + pendingRun = undefined, + onLoadState = undefined, + onSummary = undefined + }: Props = $props() + + // Every workspace-scoped call below and in the drawers this page opens (the summary's save, + // permissions, history) targets `workspace`, not the nav store: a session shows the agent of its + // own workspace, which a fork makes a different one. + setOperatingWorkspace(() => ws) + + // The user as this workspace knows them: in a fork, the navigation user is the parent's. + const operatingUser = useOperatingUser() + + /** Bumped to read the agent again after it was renamed in place. */ + let reloaded = $state(0) + let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined) + let agent = $derived(host?.draftHandle()) + let testPane = $derived(host?.testPaneHandle()) + let config = $derived(agent?.state?.args) + let chatAvailable = $derived(keepsManagedMemory(config?.memory)) + const editRights = useEditRights(() => ws) + let canAuthor = $derived(editRights.roleCanAuthor('agent')) + let canCode = $derived(editRights.capabilities.code) + let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && canAuthor) + + // Recorded once the agent is in: a path with nothing deployed never loads one. Not from a + // session's tab, as a script's page in one records nothing either. + let loaded = $derived(config != undefined) + $effect(() => { + if (ws && loaded && !embedded) { + recentItems.record(ws, { + kind: 'agent', + path, + summary: agent?.state?.description, + via: 'view' + }) + } + }) + // Reported once loaded: a host takes the first report as the name for good, an empty one included. + $effect(() => { + if (!config) return + const summary = agent?.state?.description?.trim() || undefined + untrack(() => onSummary?.(summary)) + }) + $effect(() => { + if (!agent || agent.loading) return + const state = agent.noDeployed ? 'not_found' : loaded ? 'loaded' : undefined + if (state) untrack(() => onLoadState?.(state)) + }) + + // A call waiting on a run is answered from the form, so it opens there even for an agent that + // would otherwise open on its chat. + let showedPendingForm = false + $effect(() => { + if (!pendingRun || showedPendingForm || !testPane?.mode) return + showedPendingForm = true + untrack(() => { + if (testPane) testPane.mode = 'form' + }) + }) + let args = $state<Record<string, any>>( + untrack(() => (pendingRun ? { ...pendingRun.proposedArgs } : {})) + ) + + let configModal: AgentConfigModal | undefined = $state(undefined) + let shareModal: ShareModal | undefined = $state(undefined) + let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined) + let history: AgentHistory | undefined = $state(undefined) + let deleteOpen = $state(false) + let evalsModal: AgentEvalsModal | undefined = $state(undefined) + let clientWidth = $state(window.innerWidth) + let pickedMobileTab = $state('agent') + // The page is reused across agents, and an agent without managed memory has no Slack tab. + let mobileTab = $derived(pickedMobileTab === 'slack' && !chatAvailable ? 'api' : pickedMobileTab) + let wide = $derived(clientWidth >= 1024) + + const slack = resource( + () => ({ workspace: ws, path }), + async ({ workspace, path }) => + workspace ? await SlackAgentsService.getAgentSlackChannels({ workspace, path }) : undefined + ) + let runForm: RunForm | undefined = $state(undefined) + let isValid = $state(true) + + // Cmd/Ctrl+Enter runs the form, as on a script's or a flow's page. The form stays mounted, hidden, + // while the chat shows, and the chat's composer owns the key then. + function onKeyDown(event: KeyboardEvent) { + if (!active) return + if (event.defaultPrevented || event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) return + if (!runForm || testPane?.mode === 'chat' || (!wide && mobileTab !== 'agent')) return + if (isValid) { + event.preventDefault() + runForm.run() + } else { + sendUserToast('Please fix errors before running', true) + } + } + + // The call a press answers is the one it was pressed under: one settled meanwhile — declined, + // then another proposed for this same agent — must not be answered with a job it never asked for. + let pressed: { call: PendingRun | undefined; schema: Record<string, any> } | undefined + function runStarted(jobId: string, runArgs: Record<string, any>) { + const at = pressed + pressed = undefined + // The call follows the job and shows it in place of this page, so opening the run here as + // well would leave two surfaces on one job. + if (at?.call?.adopt(jobId, runArgs, at.schema)) return + void onNavigate(`/run/${jobId}?workspace=${ws}`) + } + + async function remove() { + if (!ws || !(await deleteAgent(ws, path))) return + // Embedded, the host owns the tab: read again so it shows that nothing is deployed here, + // rather than opening Home beside a tab still showing the deleted agent. + if (embedded) reloaded++ + else await onNavigate('/?kind=agent') + } + + // The workspace's, so a path change never waits on it and a menu never outlives its path. + const deployUiSettings = resource( + () => ws, + () => getDeployUiSettings() + ) + // A role that cannot build agents gets no menu, as on a flow's page. + let menuItems = $derived( + config != undefined && canAuthor + ? agentMenuItems({ + path, + canWrite: canEdit, + noCode: !canCode, + wsSpecific: agent?.state?.wsSpecific, + deployUiSettings: deployUiSettings.current, + onPermissions: () => shareModal?.openDrawer?.(path, 'resource'), + onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'), + onHistory: () => history?.open(), + onDelete: () => (deleteOpen = true) + }).map((item) => ({ + label: item.displayName, + Icon: item.icon, + onclick: (e: MouseEvent) => item.action?.(e), + disabled: item.disabled, + color: item.type === 'delete' ? ('red' as const) : undefined + })) + : [] + ) +</script> + +<ShareModal bind:this={shareModal} /> +<DeployWorkspaceDrawer bind:this={deploymentDrawer} /> +<AgentHistory bind:this={history} {path} canRestore={canEdit} onRestored={() => reloaded++} /> +<ConfirmationModal + open={deleteOpen} + title="Delete agent" + confirmationText="Delete" + on:canceled={() => (deleteOpen = false)} + on:confirmed={() => { + deleteOpen = false + remove() + }} +> + <span>Every flow that links {path} will fail at its agent step once it is deleted.</span> +</ConfirmationModal> + +<svelte:window onkeydown={onKeyDown} /> + +<main class="h-full w-full flex flex-col"> + <!-- `labels`: the pen saves every field it shows in one write, so the labels it shows have to be + the ones the resource carries. Handed none, it would offer an empty list, and the first + label added there would replace the agent's own. --> + <DetailPageHeader + ownsPageHeader={!embedded} + itemKind="agent" + summary={config ? agent?.state?.description : undefined} + labels={agent?.state?.labels} + {path} + {menuItems} + onSaved={canEdit + ? async (newPath) => { + // A rename moves the page; a summary saved in place has to be read again, since + // what is shown here came from the load this page keys on. + if (newPath !== path) await onNavigate(`/agents/get/${newPath}`) + else reloaded++ + } + : undefined} + mainButtons={[ + // Evaluating an agent builds datasets and runs against it: authoring, as editing is, and + // eval datasets are refused to a role without `code` whatever its flows right. + ...(canEdit && canCode + ? [ + { + label: 'Evals', + buttonProps: { + variant: 'default', + unifiedSize: 'md', + startIcon: FlaskConical, + title: 'Run this agent against a dataset of cases', + onClick: () => evalsModal?.openModal() + } + } + ] + : []), + ...(canEdit + ? [ + { + label: 'Edit', + buttonProps: { + variant: 'accent', + unifiedSize: 'md', + startIcon: Pen, + href: `/agents/edit/${path}` + } + } + ] + : []) + ]} + > + <OnBehalfOfBadge + onBehalfOf={config?.run_on_behalf_of ? agent?.onBehalfOf : undefined} + onBehalfOfEmail={undefined} + kind="agent" + /> + {#snippet leading_actions()} + <!-- Only an agent that keeps the conversation can chat: without managed memory every + message would be answered alone, so it is run from its inputs with nothing to switch. --> + {#if chatAvailable && testPane?.mode} + <ToggleButtonGroup + bind:selected={ + () => testPane?.mode, + (mode) => { + if (testPane) testPane.mode = mode + } + } + noWFull + > + {#snippet children({ item })} + <ToggleButton + size="md" + value="chat" + label="Chat" + icon={MessageSquare} + tooltip="Chat with the agent: each message runs it, and it remembers the conversation" + {item} + /> + <ToggleButton + size="md" + value="form" + label="Form" + icon={FormInput} + tooltip="Run the agent once, on the inputs in a form" + {item} + /> + {/snippet} + </ToggleButtonGroup> + {/if} + {/snippet} + </DetailPageHeader> + <!-- Below `lg` the split would squeeze the chat, so the panel's sections become tabs of the + page, as on a script's or a flow's page. They cover the agent rather than replacing it: + unmounting the host destroys the chat and any turn in flight, so the agent pane keeps one + parent at every width. --> + {#if !wide && ws && config} + <Tabs + bind:selected={() => mobileTab, (t) => (pickedMobileTab = t)} + wrapperClass="flex-none no-scrollbar" + > + <Tab value="agent" label={testPane?.mode === 'chat' ? 'Chat' : 'Run form'} /> + <Tab value="api" label="API" /> + {#if chatAvailable} + <Tab value="slack" label="Slack" /> + {/if} + </Tabs> + {/if} + <div class="relative flex-1 min-h-0" bind:clientWidth> + <Splitpanes> + <Pane size={wide ? 65 : 100} minSize={50}> + <div class="h-full" inert={!wide && mobileTab !== 'agent'}> + {@render agentPane()} + </div> + </Pane> + {#if wide && ws && config} + <Pane size={35} minSize={15}> + {@render usePane(ws)} + </Pane> + {/if} + </Splitpanes> + {#if !wide && ws && config} + <div class={['absolute inset-0 z-10 bg-surface', mobileTab === 'agent' && 'hidden']}> + {@render usePane(ws, mobileTab === 'slack' ? 'slack' : 'api')} + </div> + {/if} + </div> +</main> + +{#snippet agentPane()} + {#if agent?.noDeployed && !onLoadState} + <div class="p-8 text-center text-sm text-secondary"> + Nothing is deployed at <span class="font-mono">{path}</span> yet. + </div> + {/if} + {#key `${ws}:${path}:${reloaded}`} + <AgentEditorHost + bind:this={host} + {path} + workspace={ws} + enableAi={$copilotInfo.enabled} + view + onOpenConfig={() => configModal?.open()} + onRunStarted={embedded || pendingRun ? runStarted : undefined} + > + {#snippet viewForm({ schema, run, loading, actions })} + {#if config && !chatAvailable} + <Alert type="info" size="xs" title="Managed memory is off" class="mb-4"> + Each run is answered on its own inputs, so this agent has no chat. That suits reusing it + as a step in a flow.{canEdit + ? ' Turn on managed memory in its configuration to chat with it.' + : ''} + </Alert> + {/if} + {#if pendingRun} + <div class="mb-2"> + <InputSelectedBadge inputSelected="pending_run" onReject={() => pendingRun.decline()} /> + </div> + {/if} + <RunForm + runnable={{ schema, path }} + runAction={(scheduledFor, runArgs) => { + pressed = { call: pendingRun, schema } + return run(scheduledFor, runArgs) + }} + runRefusal={pendingRun?.planModeActive ? PLAN_MODE_MESSAGES.runFormRefused : undefined} + schedulable={false} + syncArgsToUrl={!embedded} + detailed={false} + autofocus + {loading} + {actions} + bind:args + bind:isValid + bind:this={runForm} + /> + {/snippet} + </AgentEditorHost> + {/key} +{/snippet} + +{#snippet usePane(ws: string, section?: 'api' | 'slack')} + <AgentUsePanel + agentPath={path} + workspace={ws} + isAdmin={!!(operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)} + chat={chatAvailable} + slackLoading={slack.loading && !slack.current} + slackTeamName={slack.current?.slack_team_name} + botName={slack.current?.bot_name} + missingScopes={slack.current?.missing_scopes ?? []} + signingSecretSet={slack.current?.signing_secret_set ?? true} + runsAs={slack.current?.runs_as} + anyone={slack.current?.anyone ?? false} + canShare={slack.current?.can_share ?? false} + channels={slack.current?.channels ?? []} + {section} + /> +{/snippet} + +{#if ws} + <AgentEvalsModal bind:this={evalsModal} agentPath={path} workspace={ws} /> +{/if} + +{#if config} + <AgentConfigModal bind:this={configModal} {config} toolSchema={(id) => host?.toolSchema(id)} /> +{/if} diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index cdec419e5a..9a0d306ac7 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -3,7 +3,7 @@ import { writable } from 'svelte/store' import { MessageCircleOff, SlidersHorizontal } from 'lucide-svelte' import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' - import { Alert, Button } from '$lib/components/common' + import { Alert, Button, Tab, Tabs } from '$lib/components/common' import FlowChat from '../conversations/FlowChat.svelte' import { AGENT_CHAT_SCHEMA, @@ -71,6 +71,9 @@ view?: boolean /** Opens the agent's configuration, offered beside the model it runs with. */ onOpenConfig?: () => void + /** Takes a view's run once it has started, instead of opening its run page: for a host that + * is not the app's top-level route (a session tab), or a call waiting on that run. */ + onRunStarted?: (jobId: string, args: Record<string, any>) => void /** A view's form mode: the run form, drawn by the page. Kept out of this component because * the flow editor reaches it, and the run form reads the navigation workspace. */ viewForm?: import('svelte').Snippet< @@ -95,6 +98,7 @@ isNew = false, view = false, onOpenConfig = undefined, + onRunStarted = undefined, viewForm = undefined }: Props = $props() @@ -272,6 +276,13 @@ if (mode === 'chat') chatMounted = true } + // Too narrow for the two panes side by side (a session's side panel), they take turns as tabs: + // each needs about 400px, the test pane's floor in the script editor too. Both stay mounted, + // sized to nothing when hidden: unmounting the test pane would end its chat. + let editorWidth = $state(0) + let narrow = $derived(editorWidth > 0 && editorWidth < 900) + let narrowTab = $state<'config' | 'test'>('config') + // draft.args -> module. Only when the draft moved on its own (a load, an external write, a // discard); an edit made in the form arrives here as its own echo and is skipped. $effect(() => { @@ -407,7 +418,8 @@ runLoading = true try { const id = await JobService.runAgent({ workspace, path, requestBody: args }) - await goto(`/run/${id}?workspace=${workspace}`) + if (onRunStarted) onRunStarted(id, args) + else await goto(`/run/${id}?workspace=${workspace}`) } finally { runLoading = false } @@ -509,7 +521,9 @@ return testMode }, set mode(mode: 'form' | 'chat' | undefined) { - if (mode) showTestMode(mode) + if (!mode) return + showTestMode(mode) + narrowTab = 'test' } } } @@ -567,16 +581,32 @@ {:else if agentModule && agentValue} <!-- Named and positioned so the tool picker's popover can portal here: the `#flow-editor` it otherwise targets is behind this dialog, and does not exist at all on the resources page. --> - <div id="agent-editor" class="relative h-full min-h-0"> + <!-- The editor's hosts leave the horizontal padding to it: side by side the panes sit inside + it, and as tabs the strip runs edge to edge while each pane keeps its own. --> + <div + id="agent-editor" + class="relative h-full min-h-0 flex flex-col {!view && !narrow ? 'px-4 sm:px-6' : ''}" + bind:clientWidth={editorWidth} + > {#if view} <!-- A view only runs the agent: the page beside it summarizes the configuration. --> {@render runPane()} {:else} + {#if narrow} + <Tabs bind:selected={narrowTab} class="shrink-0"> + <Tab value="config" label="Configuration" /> + <Tab value="test" label="Test" /> + </Tabs> + {/if} <!-- Resizable as the step panel's config and test are: a long system prompt and a long answer want opposite splits, and only the reader knows which they are on. --> - <Splitpanes class="h-full"> - <Pane size={55} minSize={30}>{@render configPane()}</Pane> - <Pane size={45} minSize={20}>{@render runPane()}</Pane> + <Splitpanes class="flex-1 min-h-0 {narrow ? '[&>.splitpanes__splitter]:!hidden' : ''}"> + <Pane size={narrow ? (narrowTab === 'config' ? 100 : 0) : 55} minSize={narrow ? 0 : 30}> + {@render configPane()} + </Pane> + <Pane size={narrow ? (narrowTab === 'test' ? 100 : 0) : 45} minSize={narrow ? 0 : 20}> + {@render runPane()} + </Pane> </Splitpanes> {/if} </div> diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index 7fde7a56f4..2de3651804 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -9,7 +9,10 @@ import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common' import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte' - import { clearPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' + import { + clearPageDrawerAnchor, + persistDraftForSession + } from '$lib/components/sessions/pageDrawerSession' import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths' import EvalsPane from '$lib/components/aiEvals/EvalsPane.svelte' import type { EvalsLocation } from '$lib/components/aiEvals/evalUtils' @@ -36,6 +39,11 @@ import { emptyString } from '$lib/utils' import Tooltip from '$lib/components/Tooltip.svelte' import { useEditRights } from '$lib/operatorWriteRights' + import { itemDisplayName } from '$lib/components/sessions/previewRouter' + import { UserDraft } from '$lib/userDraft.svelte' + import OpenInSessionButton, { + type OpenInSessionSource + } from '$lib/components/sessions/OpenInSessionButton.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -50,8 +58,10 @@ /** A deploy moved the agent from `from` to `to`. What names the old path belongs to the surface * that opened the editor: a flow's own steps, a page's URL. */ onRenamed?: (from: string, to: string) => void - /** `page` fills its container with the editor's header and body instead of opening a dialog. */ - layout?: 'modal' | 'page' + /** `page` fills its container with the editor's header and body instead of opening a dialog, + * the header going to the route's page header; `pane` draws that header itself, for a host + * nested in another page (a session tab) whose page header is not this editor's. */ + layout?: 'modal' | 'page' | 'pane' /** Where closing leads, for a layout that has no dialog to dismiss. `deployed` is false for an * agent that exists only as a draft, which has no page of its own to return to. */ onClose?: (deployed: boolean) => void @@ -59,6 +69,13 @@ onDeployed?: (path: string) => void /** Open on the settings, as a link to them does. */ startInSettings?: boolean + /** The agent this mount shows, held here rather than in the editor every other mount shares, + * for a host that shows several at once (a session's tabs). Navigating and closing then stay + * with this mount, and `owns` is not consulted. */ + fixedTarget?: AgentEditorTarget + /** The agent's name as it stands in the editor, edits included, for a host that names it: its + * summary, or for a never-deployed agent parked at `draft_<uuid>`, the path it will deploy to. */ + onSummary?: (summary: string | undefined) => void } let { @@ -68,17 +85,29 @@ layout = 'modal', onClose = undefined, onDeployed = undefined, - startInSettings = false + startInSettings = false, + fixedTarget = undefined, + onSummary = undefined }: Props = $props() // Every target names the surface that opened it, and only a flow step or a resource row can: // an agent used as a tool of the agent being edited stays part of it, with no way in this editor // to link it to a saved agent of its own. So `owns` decides alone — nothing here has to adopt a // target opened from inside another editor. + let local = $state<AgentEditorTarget | undefined>(untrack(() => fixedTarget)) let target = $derived.by(() => { + if (local) return local const t = agentEditorTarget() return t && owns(t) ? t : undefined }) + function showTool(toolId: string | undefined) { + if (local) local = { ...local, toolId, view: undefined } + else showAgentEditorTool(toolId) + } + function showView(view: AgentEditorTarget['view']) { + if (local) local = { ...local, view, toolId: undefined } + else showAgentEditorView(view) + } // The last agent this dialog showed. The version pane keeps working after `onRestore` returns — // it reloads its list keyed on the path it was given — while the restore handler has already // closed the dialog and dropped the target. Reading the live one there throws, and the throw @@ -90,6 +119,23 @@ }) let ws = $derived(target?.workspace ?? $operatingWorkspace) + // On its own route, the hand-off the script and flow editors have: a session on this agent's + // editor tab, opened once what is on screen is saved. + let sessionSource = $derived.by((): OpenInSessionSource | undefined => { + const path = target?.path + const workspace = ws + if (layout !== 'page' || !path || !workspace) return undefined + return { + page: () => `${RESOURCES_PATH}#/resource/${path}`, + workspaceId: workspace, + beforeOpen: async () => { + await persistDraftForSession(workspace, 'resource', path) + // A new agent has no draft row until an edit autosaves one, and the session reads the + // agent from the server. Only for one never deployed, which has no baseline to skip. + if (draftOnly) await UserDraft.forcePersist('resource', path, { workspace }) + } + } + }) let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined) let versionDrawer: Drawer | undefined = $state(undefined) let settingsDrawer: Drawer | undefined = $state(undefined) @@ -151,6 +197,13 @@ // stored at a minted `draft_<uuid>` path and named by the path its first deploy will create, // as the home list names it. Falls back to the target while the draft is still loading. let shownPath = $derived(draft?.state?.path || target?.path) + // Reported once loaded, then on every rename: from the first report on, the host names the tab by + // it rather than by its listing, an empty name included. + $effect(() => { + if (!draft?.state) return + const name = itemDisplayName(target?.path ?? '', draft.state.path, draft.state.description) + untrack(() => onSummary?.(name)) + }) // Where the evals pane is within itself, so its levels extend this dialog's trail rather than // opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on @@ -166,7 +219,7 @@ let root = $derived<ModalTrailSegment>({ label: shownPath ?? 'Agent', - onclick: inEvals || inSettings ? () => showAgentEditorView(undefined) : undefined + onclick: inEvals || inSettings ? () => showView(undefined) : undefined }) let trail = $derived<ModalTrailSegment[]>( inEvals @@ -185,12 +238,12 @@ // The dialog's levels are its pages; a page has none, so evals open over it as a dialog. function openEvals() { - if (layout === 'modal') showAgentEditorView('evals') + if (layout === 'modal') showView('evals') else evalsModal?.openModal() } function openSettings() { - if (layout === 'modal') showAgentEditorView('settings') + if (layout === 'modal') showView('settings') else settingsDrawer?.openDrawer() } @@ -219,7 +272,7 @@ function close() { // Unknown until the load answers, and a detail page that does not exist is the worse guess. const deployed = draft != undefined && !draft.loading && !draftOnly && !refused - closeAgentEditor() + if (!local) closeAgentEditor() if (layout === 'modal') void clearPageDrawerAnchor(RESOURCES_PATH) onClose?.(deployed) } @@ -229,6 +282,7 @@ // way back and tells the flow editor a dialog is over its graph. Only its own, so a mount going // down never drops a target another one is showing. onDestroy(() => { + if (local) return const t = agentEditorTarget() if (t && owns(t)) closeAgentEditor() }) @@ -298,7 +352,9 @@ // The dialog is keyed on the path, so this reloads it on the renamed agent. Only while it // still shows the one deployed: it can be closed or pointed elsewhere mid-request. if (target?.path === at.path) { - openAgentEditor({ path: savedPath, workspace: target.workspace, host: target.host }) + const next = { path: savedPath, workspace: target.workspace, host: target.host } + if (local) local = next + else openAgentEditor(next) } } await reconciled @@ -331,6 +387,19 @@ > {@render body()} </Modal> + {:else if layout === 'pane'} + <div class="h-full min-h-0 flex flex-col"> + <div class="flex items-center gap-2 px-4 sm:px-6 py-2 border-b min-w-0 shrink-0"> + <div class="min-w-0 flex-1 flex items-center gap-1"> + {@render agentSummary()} + {@render agentHint()} + </div> + {@render settings()} + </div> + <div class="flex-1 min-h-0 px-4 sm:px-6"> + {@render body()} + </div> + </div> {:else} <!-- On its own route the editor's header is the page header: the agent's path is the breadcrumb, the level it is on reads after that name, and its controls are the band's @@ -346,6 +415,7 @@ }} afterName={agentHint} actions={settings} + {sessionSource} separator="always" /> <div class="h-full min-h-0 flex flex-col"> @@ -533,6 +603,7 @@ title="Version history" on:click={() => versionDrawer?.openDrawer()} /> + <OpenInSessionButton source={sessionSource} /> <Button unifiedSize="sm" variant="accent" @@ -566,9 +637,11 @@ them for its own levels. Warmed, as those levels are: a page built on its first visit lands inside the transition and arrives empty. The page layout has no levels: it opens settings in a drawer and evals in a dialog, so warming them there would build them twice. --> + <!-- Full-bleed as the banner is, each page padding itself: the editor drops its padding + when its panes become tabs, and this clips anything that reaches past it. --> <PagedContent warm - class="flex-1 min-h-0" + class="flex-1 min-h-0 -mx-4 sm:-mx-6" current={inEvals ? 'evals' : inSettings ? 'settings' : 'agent'} pages={[ { key: 'agent', content: agentPage }, @@ -589,7 +662,7 @@ workspace={ws} {enableAi} toolId={target?.toolId} - onSelectTool={(id) => showAgentEditorTool(id)} + onSelectTool={(id) => showTool(id)} {onSaved} isNew={target?.isNew} /> @@ -608,20 +681,22 @@ {/snippet} {#snippet settingsPage()} - <div class="max-w-2xl py-6">{@render settingsFields()}</div> + <div class="max-w-2xl py-6 px-4 sm:px-6">{@render settingsFields()}</div> {/snippet} {#snippet evalsPage()} - <EvalsPane - agentPath={target?.path ?? ''} - opWorkspace={ws} - editedConfig={draft?.sync.hasDraft ? editedConfig : undefined} - bind:location={evalsLocation} - active={inEvals} - /> + <div class="h-full min-h-0 px-4 sm:px-6"> + <EvalsPane + agentPath={target?.path ?? ''} + opWorkspace={ws} + editedConfig={draft?.sync.hasDraft ? editedConfig : undefined} + bind:location={evalsLocation} + active={inEvals} + /> + </div> {/snippet} - {#if layout === 'page' && ws} + {#if layout !== 'modal' && ws} <AgentEvalsModal bind:this={evalsModal} agentPath={target?.path ?? ''} diff --git a/frontend/src/lib/components/flows/conversations/agentEditorChat.test.ts b/frontend/src/lib/components/flows/conversations/agentEditorChat.test.ts index 99db5b8a9f..b6ed918d01 100644 --- a/frontend/src/lib/components/flows/conversations/agentEditorChat.test.ts +++ b/frontend/src/lib/components/flows/conversations/agentEditorChat.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import type { FlowModule } from '$lib/gen' -import { agentChatFlow, agentChatGap, agentChatPath, agentStepLabel } from './agentEditorChat' +import { + agentChatFlow, + agentChatGap, + agentChatPath, + agentRunHref, + agentStepLabel +} from './agentEditorChat' describe('agentChatPath', () => { it('is a path no flow can take, so a same-path flow never shares its conversations', () => { @@ -10,6 +16,27 @@ describe('agentChatPath', () => { }) }) +describe('agentRunHref', () => { + const agentFlow = { modules: [{ id: '__wm_agent_root' }] } + it('links a chat run to the agent editor and a one-shot run to its page', () => { + expect(agentRunHref({ job_kind: 'flowpreview', script_path: 'u/a/bot.chat' }, 'ws')).toBe( + '/agents/edit/u/a/bot?workspace=ws' + ) + expect( + agentRunHref({ job_kind: 'flowpreview', script_path: 'u/a/bot', raw_flow: agentFlow }, 'ws') + ).toBe('/agents/get/u/a/bot?workspace=ws') + }) + it('leaves any other flow preview alone', () => { + expect( + agentRunHref( + { job_kind: 'flowpreview', script_path: 'u/a/flow', raw_flow: { modules: [{ id: 'a' }] } }, + 'ws' + ) + ).toBeUndefined() + expect(agentRunHref({ job_kind: 'flow', script_path: 'u/a/bot.chat' }, 'ws')).toBeUndefined() + }) +}) + describe('agentStepLabel', () => { it("names a run's agent step by the agent, a chat turn's included", () => { expect(agentStepLabel('f/support/agent')).toBe('f/support/agent') diff --git a/frontend/src/lib/components/flows/conversations/agentEditorChat.ts b/frontend/src/lib/components/flows/conversations/agentEditorChat.ts index 0f1bc86bda..e4484c9855 100644 --- a/frontend/src/lib/components/flows/conversations/agentEditorChat.ts +++ b/frontend/src/lib/components/flows/conversations/agentEditorChat.ts @@ -17,6 +17,26 @@ export function agentChatPath(agentPath: string): string { * scoping and the run's agent detection match on it, so it is only ever relabeled for display. */ export const AGENT_STEP_ID = '__wm_agent_root' +/** + * Where a run's path links when the run is an agent's, which is a flow preview filed under no flow: + * a test run (`.chat`) to the agent's editor, a run of the deployed agent to its page. Undefined + * for any other run. + */ +export function agentRunHref( + job: { job_kind?: string; script_path?: string; raw_flow?: { modules?: { id: string }[] } }, + workspace: string | undefined +): string | undefined { + const path = job.script_path + if (job.job_kind !== 'flowpreview' || !path) return undefined + const query = workspace ? `?workspace=${workspace}` : '' + if (path.endsWith('.chat')) return `/agents/edit/${path.slice(0, -'.chat'.length)}${query}` + const modules = job.raw_flow?.modules + if (modules?.length === 1 && modules[0].id === AGENT_STEP_ID) { + return `/agents/get/${path}${query}` + } + return undefined +} + /** What a run shows for that step: the agent it ran, read off the run's path. */ export function agentStepLabel(runPath: string | undefined): string { return runPath?.replace(/\.chat$/, '') || 'AI Agent' diff --git a/frontend/src/lib/components/mcp/McpAccessPicker.svelte b/frontend/src/lib/components/mcp/McpAccessPicker.svelte index 6466bb8761..30f3adb733 100644 --- a/frontend/src/lib/components/mcp/McpAccessPicker.svelte +++ b/frontend/src/lib/components/mcp/McpAccessPicker.svelte @@ -99,7 +99,7 @@ {item} value="read_run" label="Read and run" - tooltip="Explore, and run deployed scripts and flows. The client changes nothing itself; the scripts and flows it runs can." + tooltip="Explore, and run deployed scripts, flows and agents. The client changes nothing itself; what it runs can." /> <ToggleButton {item} diff --git a/frontend/src/lib/components/runs/JobDetailFieldConfig.ts b/frontend/src/lib/components/runs/JobDetailFieldConfig.ts index 72845b0820..0ab02b4b98 100644 --- a/frontend/src/lib/components/runs/JobDetailFieldConfig.ts +++ b/frontend/src/lib/components/runs/JobDetailFieldConfig.ts @@ -2,6 +2,7 @@ import type { Job } from '$lib/gen' import { triggerDisplayNamesMap, triggerIconMap } from '$lib/components/triggers/utils' import { formatMemory } from '$lib/utils' import { flowPathToHref } from '$lib/scripts' +import { agentRunHref } from '$lib/components/flows/conversations/agentEditorChat' import { Calendar, Bot } from 'lucide-svelte' import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' @@ -264,10 +265,12 @@ export const fieldConfigs: Record<JobField, FieldConfig> = { field: 'script_path', label: 'Path', getValue: (job) => job.script_path || null, - getHref: (job, _workspaceId) => { + getHref: (job, workspaceId) => { if (!job.script_path) return null const isScript = job.job_kind === 'script' - return isScript ? `/scripts/get/${job.script_hash}` : flowPathToHref(job.script_path) + return isScript + ? `/scripts/get/${job.script_hash}` + : (agentRunHref(job, workspaceId) ?? flowPathToHref(job.script_path)) } }, diff --git a/frontend/src/lib/components/runs/JobDetailHeader.svelte b/frontend/src/lib/components/runs/JobDetailHeader.svelte index 1caf43fbfb..e676bf609b 100644 --- a/frontend/src/lib/components/runs/JobDetailHeader.svelte +++ b/frontend/src/lib/components/runs/JobDetailHeader.svelte @@ -28,6 +28,7 @@ type FieldConfig } from './JobDetailFieldConfig' import { flowPathToHref } from '$lib/scripts' + import { agentRunHref } from '$lib/components/flows/conversations/agentEditorChat' import { slide } from 'svelte/transition' import { twMerge } from 'tailwind-merge' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -473,7 +474,7 @@ {@const isScript = job.job_kind === 'script'} {@const viewHref = isScript ? `/${stem}/get/${job?.script_hash}` - : flowPathToHref(job?.script_path ?? '')} + : (agentRunHref(job, $operatingWorkspace) ?? flowPathToHref(job?.script_path ?? ''))} <a href={viewHref} target="_blank" diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index d1adaa952f..93e62702f8 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -22,6 +22,7 @@ barePath } from './previewRouter' import { useSessionPermissions } from './sessionPermissions.svelte' + import { agentWriteCount } from '$lib/components/flows/agentEditorStore.svelte' let { runtime, @@ -41,7 +42,7 @@ tabId: string /** The tab's host element, which also holds the overlays the page portals out. */ container: HTMLElement | undefined - kind: SessionTargetKind + kind: SessionTargetKind | 'agent' path: string /** Deployed version this tab is pinned to, from its URL's `?version=`. */ version?: string @@ -76,7 +77,10 @@ // bumps this counter through `itemDeployed`; a deploy from the chat arrives // instead as `refresh()`, via `previewReload`'s item list. Neither covers a deploy made // outside the session (Compare & Deploy, the CLI, another browser tab). - const revision = $derived(runtime.deployedRevision(kind, path)) + // An agent is deployed from its own editor, which counts its writes for every surface showing it. + const revision = $derived( + kind === 'agent' ? agentWriteCount(workspaceId, path) : runtime.deployedRevision(kind, path) + ) let reloadKey = $state(0) // Seeded from the current value on purpose: before the first run this holds "no deploy // seen yet", so a fresh mount must not count as one. @@ -133,6 +137,10 @@ } function toEditSide() { + if (kind === 'agent') { + runtime.previewTabs.open({ type: 'pageitem', ref: { kind: 'resource', path } }) + return + } onNavigate( kind === 'flow' ? `/flows/edit/${path}` @@ -148,6 +156,22 @@ function onNavigate(url: string): void { const clean = barePath(url) const query = new URL(url, 'http://x').searchParams + // An agent's editor is its resource tab; another agent's page is this same view. + const agentEdit = clean.match(/^\/agents\/edit\/(.+)$/) + if (agentEdit) { + runtime.previewTabs.open({ + type: 'pageitem', + ref: { kind: 'resource', path: decodeURIComponent(agentEdit[1]) } + }) + return + } + if (/^\/agents\/get\/.+$/.test(clean)) { + runtime.previewTabs.navigate( + { type: 'page', href: clean, label: previewLocationLabel(clean) }, + tabId + ) + return + } const run = clean.match(/^\/run\/([^/?#]+)$/) if (run) { const jobId = decodeURIComponent(run[1]) @@ -234,7 +258,7 @@ <div class="text-sm text-primary">Not deployed yet</div> <div class="text-xs text-tertiary max-w-sm"> Nothing is deployed at <span class="font-mono">{path}</span>, so there is no deployed - version to view.{#if canEdit} + version to view.{#if canEdit}{' '} Deploy it from the editor to see it here.{/if} </div> {#if canEdit} @@ -243,6 +267,20 @@ </Button> {/if} </div> + {:else if kind === 'agent'} + <!-- Loaded when shown: the agent page carries the agent editor, the flow editor's graph. --> + {#await import('$lib/components/flows/content/AgentDetailPage.svelte') then Module} + <Module.default + {path} + workspace={workspaceId} + embedded + {active} + {onNavigate} + {pendingRun} + onLoadState={setLoadState} + onSummary={(summary) => runtime.previewTabs.setTabFriendlyLabel(tabId, summary)} + /> + {/await} {:else if kind === 'script'} <ScriptDetail hash={path} diff --git a/frontend/src/lib/components/sessions/PageItemEditorView.svelte b/frontend/src/lib/components/sessions/PageItemEditorView.svelte index a77550a3ef..7240436312 100644 --- a/frontend/src/lib/components/sessions/PageItemEditorView.svelte +++ b/frontend/src/lib/components/sessions/PageItemEditorView.svelte @@ -2,6 +2,7 @@ import { setContext, untrack } from 'svelte' import { Loader2 } from 'lucide-svelte' import { enterpriseLicense } from '$lib/stores' + import { copilotInfo } from '$lib/aiStore' import { resource } from 'runed' import { Button } from '$lib/components/common' import { lookupPageItem } from './pageItemLookup' @@ -81,9 +82,7 @@ const t = triggerEditor untrack(() => { v?.editVariable(path) - // An agent opens on JSON: the generic form would render its configuration field by - // field and write a default into each one the value leaves out, drafting just by opening. - void r?.initEdit(path, { json: lookup.current?.resourceType === 'ai_agent' }) + void r?.initEdit(path) void t?.openEdit(path, false) }) }) @@ -170,6 +169,21 @@ {onSaved} onClose={closeTab} /> + {:else if item.kind === 'resource' && lookup.current.resourceType === 'ai_agent'} + <!-- An agent has an editor of its own, which the resource editor would show as JSON. --> + {#await import('$lib/components/flows/content/AgentEditorModal.svelte')} + {@render loading()} + {:then Module} + <Module.default + layout="pane" + fixedTarget={{ path: item.path, workspace: workspaceId }} + owns={() => false} + enableAi={$copilotInfo.enabled} + onClose={closeTab} + onRenamed={(_, to) => onSaved(to)} + onSummary={(summary) => runtime.previewTabs.stampAgentTab(item, summary)} + /> + {/await} {:else if item.kind === 'resource'} <ResourceEditorDrawer bind:this={resourceEditor} diff --git a/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte b/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte index 8fa6b465cc..359c73c998 100644 --- a/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte +++ b/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte @@ -16,9 +16,10 @@ section or the flat layout. <script lang="ts"> import { untrack } from 'svelte' - import { Compass, FileText, NetworkIcon, Pen } from 'lucide-svelte' + import { Bot, Compass, FileText, NetworkIcon, Pen } from 'lucide-svelte' import { resource } from 'runed' import { AssetService } from '$lib/gen' + import { listWorkspaceAgents } from './workspaceAgents' import { workspaceStore } from '$lib/stores' import RowIcon from '$lib/components/common/table/RowIcon.svelte' import { @@ -186,6 +187,17 @@ section or the flat layout. // draft-only ones a plain click already opens there: a row that drops the button to say // "you were getting this anyway" reads as one that cannot be edited. function editAction(leaf: DrillLeaf<PreviewTarget>) { + const agentPath = agentPathOf(leaf) + if (agentPath !== undefined) { + return canEditAgents + ? { + icon: Pen, + label: 'Edit', + title: `Edit ${agentPath}`, + run: () => onPick(agentEditTarget(agentPath)) + } + : undefined + } const target = leaf.data if (target.type !== 'item' || !canEdit(target.item)) return undefined return { @@ -199,6 +211,13 @@ section or the flat layout. // What the row says about the item's deploy state, in the review dock's words so // the two never describe the same item differently. function stateBadge(leaf: DrillLeaf<PreviewTarget>) { + const agentPath = agentPathOf(leaf) + if (agentPath !== undefined) { + const agent = shownAgents.find((a) => a.path === agentPath) + if (agent?.draftOnly) return { label: 'Draft only', color: 'indigo' as const } + if (agent?.hasDraft) return { label: 'Draft', color: 'indigo' as const } + return undefined + } const target = leaf.data if (target.type !== 'item') return undefined if (target.item.draftOnly) return { label: 'Draft only', color: 'indigo' as const } @@ -278,11 +297,51 @@ section or the flat layout. : undefined ) + // Agents are resources rather than workspace items, so they get a branch of their own. A + // deployed one lands on its page, as picking any item does; a draft-only one has only its + // editor, so it is offered only to a user who can open that. + const agentsResource = resource( + () => effectiveWorkspace, + (ws) => (ws ? listWorkspaceAgents(ws) : Promise.resolve([])) + ) + const canEditAgents = $derived(permissions.canOpenEditor('agent')) + function agentEditTarget(path: string): PreviewTarget { + return { type: 'pageitem', ref: { kind: 'resource', path } } + } + const shownAgents = $derived( + (agentsResource.current ?? []).filter((a) => canEditAgents || !a.draftOnly) + ) + const agentsBranch = $derived<DrillBranch<PreviewTarget> | undefined>( + shownAgents.length > 0 + ? { + type: 'branch', + key: 'agents', + label: 'AI agents', + icon: Bot, + searchGroup: true, + children: shownAgents.map((a) => ({ + type: 'leaf' as const, + key: `agent:${a.path}`, + label: a.summary || a.path, + secondary: a.summary ? a.path : undefined, + icon: Bot, + data: a.draftOnly + ? agentEditTarget(a.path) + : { type: 'page', href: `/agents/get/${a.path}`, label: a.summary || a.path } + })) + } + : undefined + ) + function agentPathOf(leaf: DrillLeaf<PreviewTarget>): string | undefined { + return leaf.key.startsWith('agent:') ? leaf.key.slice('agent:'.length) : undefined + } + const tree = $derived<DrillNode<PreviewTarget>[]>([ // An operator's workspace may enable none of them. ...(pagesBranch.children.length > 0 ? [pagesBranch] : []), ...(artifactsBranch ? [artifactsBranch] : []), ...(pipelinesBranch ? [pipelinesBranch] : []), + ...(agentsBranch ? [agentsBranch] : []), ...tagItems( buildWorkspaceTree({ loaded: loader.loaded, diff --git a/frontend/src/lib/components/sessions/SessionChangesBar.svelte b/frontend/src/lib/components/sessions/SessionChangesBar.svelte index b7ee23034c..ca3cbd0941 100644 --- a/frontend/src/lib/components/sessions/SessionChangesBar.svelte +++ b/frontend/src/lib/components/sessions/SessionChangesBar.svelte @@ -31,6 +31,8 @@ import { previewTargetForDeployKind } from './sessionPreviewTabs.svelte' import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths' import type { PreviewTarget } from './previewRouter' + import { ResourceService } from '$lib/gen' + import { resource } from 'runed' import JobsSegment from '$lib/components/copilot/chat/JobsSegment.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' import ArtifactsSegment from '$lib/components/copilot/chat/artifacts/ArtifactsSegment.svelte' @@ -163,6 +165,31 @@ : [] ) const dockCounts = $derived(badgeCounts(dockItems)) + // An agent is a resource the draft list cannot tell apart, but it has an icon of its own. + // Keyed on the joined paths so a re-derived list doesn't re-fetch. + const resourcePaths = $derived( + dockItems + .filter((i) => i.deployKind === 'resource') + .map((i) => i.path) + .join('\n') + ) + const agentPaths = resource( + () => [committedId, resourcePaths] as const, + async ([workspace, joined]) => { + if (!workspace || !joined) return new Set<string>() + const types = await Promise.all( + joined.split('\n').map((path) => + ResourceService.getResource({ workspace, path, getDraft: true }).then( + (r) => (r.resource_type === 'ai_agent' ? path : undefined), + () => undefined + ) + ) + ) + return new Set(types.filter((p) => p !== undefined)) + } + ) + const isAgentRow = (item: DeployItem) => + item.deployKind === 'resource' && (agentPaths.current?.has(item.path) ?? false) // Deletion-only fork chat: the mask has entries but every one failed the // (resolved) existence check — the chat's edits were deletions, which have no @@ -210,7 +237,11 @@ function previewTargetFor(item: DeployItem): PreviewTarget | undefined { // `done` means this session already deployed it, so there is a deployed // version to show even though the row was built from a draft-only item. - return previewTargetForDeployKind(item.deployKind, item.path, item.draftOnly && !item.done) + const draftOnly = item.draftOnly && !item.done + if (isAgentRow(item) && !draftOnly) { + return { type: 'page', href: `/agents/get/${item.path}`, label: rowLabel(item) } + } + return previewTargetForDeployKind(item.deployKind, item.path, draftOnly) } // The row's primary action is the preview; kinds the panel can't host fall back @@ -331,7 +362,11 @@ maxHeightClass="max-h-[min(9rem,50vh)]" > {#snippet row(item)} - <RowIcon kind={item.deployKind} path={item.path} size={14} /> + <RowIcon + kind={isAgentRow(item) ? 'agent' : item.deployKind} + path={item.path} + size={14} + /> <span class="min-w-0 flex-1 truncate font-mono font-normal text-primary"> {rowLabel(item)} </span> @@ -389,6 +424,7 @@ {parentWorkspaceId} chatId={session.chatId} keys={mask} + agentPaths={agentPaths.current} onDataChanged={refreshDock} onItemDeployed={(item) => void runtime?.manager.renameModifiedItem(item.draftKind, item.path, item.displayPath)} diff --git a/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte b/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte index 76d7f7e7ab..8434aa6f74 100644 --- a/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte @@ -21,7 +21,8 @@ onItemDeployed, onItemDiscarded, deployPipeline, - readOnly = false + readOnly = false, + agentPaths }: { workspaceId: string parentWorkspaceId?: string @@ -42,6 +43,8 @@ deployPipeline?: (folder: string) => Promise<DeployResult> /** Forwarded to the deploy model: the session is in Use. */ readOnly?: boolean + /** Resource rows that are agents, which get the agent's icon and editor. */ + agentPaths?: Set<string> } = $props() const isFork = $derived(!!parentWorkspaceId) @@ -70,9 +73,13 @@ if (item.deployKind === 'script') return `/scripts/edit/${path}?workspace=${ws}` if (item.deployKind === 'app') return `/apps/edit/${path}?workspace=${ws}` if (item.deployKind === 'raw_app') return `/apps_raw/edit/${path}?workspace=${ws}` + if (isAgent(item)) return `/agents/edit/${path}?workspace=${ws}` return undefined } + const isAgent = (item: DeployItem) => + item.deployKind === 'resource' && (agentPaths?.has(item.path) ?? false) + const title = 'Edited during session' // Compare page = the batch/PR surface. The footer hands off THIS session's @@ -92,6 +99,7 @@ {model} {title} {editUrlFor} + iconKindFor={(item) => (isAgent(item) ? 'agent' : undefined)} {compareSessionHref} workspaceLabel={ws?.name ?? workspaceId} > diff --git a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte index 11f3219155..c308902d4d 100644 --- a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte @@ -15,7 +15,9 @@ User } from 'lucide-svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' - import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte' + import WorkspaceItemRow, { + type WorkspaceItemRowKind + } from '$lib/components/WorkspaceItemRow.svelte' import WorkspaceItemDiffViewer from '$lib/components/WorkspaceItemDiffViewer.svelte' import { rawAppDiffToItems, @@ -50,6 +52,7 @@ model, title, editUrlFor = undefined, + iconKindFor = undefined, titleExtra, compareSessionHref, workspaceLabel, @@ -59,6 +62,8 @@ title: string /** Editor URL for a row (opens in the workspace the item lives in). */ editUrlFor?: (item: DeployItem) => string | undefined + /** Icon kind for a row whose deploy kind doesn't name it, e.g. an agent's resource. */ + iconKindFor?: (item: DeployItem) => WorkspaceItemRowKind | undefined titleExtra?: Snippet /** Display name of the workspace deploys land in — the deployed tick's * hover reads "Deployed in <label>". */ @@ -783,7 +788,7 @@ </WorkspaceItemRow> {:else} <WorkspaceItemRow - kind={d.deployKind as any} + kind={(iconKindFor?.(d) ?? d.deployKind) as any} iconPath={d.path} baseClass="py-2 min-w-0 pr-1 pl-1 rounded-md" singleLine @@ -1027,7 +1032,11 @@ bind:clientHeight={cardHeaderH} class="sticky top-0 z-30 bg-surface-tertiary rounded-t-md flex items-center gap-2 px-3 py-2 border-b border-transparent" > - <RowIcon kind={d.deployKind as any} path={d.path} size={14} /> + <RowIcon + kind={(iconKindFor?.(d) ?? d.deployKind) as any} + path={d.path} + size={14} + /> <!-- flex+items-center so the path (a block div, or ExternalEditLink's inline-flex <a>) is centered by its box against the icon — an inline <a> would otherwise sit ~2px low on the line-box baseline. --> diff --git a/frontend/src/lib/components/sessions/pageDrawerSession.ts b/frontend/src/lib/components/sessions/pageDrawerSession.ts index 35d0a4560b..99137be3f0 100644 --- a/frontend/src/lib/components/sessions/pageDrawerSession.ts +++ b/frontend/src/lib/components/sessions/pageDrawerSession.ts @@ -127,11 +127,19 @@ export function pageDrawerSessionSource( // Text that does not parse never reaches the draft, so routing is refused instead. beforeOpen: itemKind && workspaceId - ? async () => { - flushAllPendingEditorChanges() - await tick() - await flushOrRefuse({ workspace: workspaceId, itemKind, path: itemPath }) - } + ? () => persistDraftForSession(workspaceId, itemKind, itemPath) : undefined } } + +/** Save the draft of the item on screen before a session opens it, or throw saying why it cannot + * be: the preview reads the draft back through the server, from a document of its own. */ +export async function persistDraftForSession( + workspace: string, + itemKind: UserDraftItemKind, + path: string +): Promise<void> { + flushAllPendingEditorChanges() + await tick() + await flushOrRefuse({ workspace, itemKind, path }) +} diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts index 31c610889d..8f05599066 100644 --- a/frontend/src/lib/components/sessions/previewPaths.ts +++ b/frontend/src/lib/components/sessions/previewPaths.ts @@ -166,6 +166,18 @@ export function barePath(path: string): string { ) } +/** The agent a `/agents/get/<path>` location shows; undefined for any other location, a malformed + * encoding included, which `decodeURIComponent` would otherwise throw on mid-render. */ +export function agentPagePath(loc: string): string | undefined { + const m = barePath(loc).match(/^\/agents\/get\/(.+)$/) + if (!m) return undefined + try { + return decodeURIComponent(m[1]) + } catch { + return undefined + } +} + /** Which side of an item a route addresses: `/edit/` builds it, `/get/` shows the * deployed version. The two are one tab in a session, switched by re-pointing its URL. */ export type PreviewItemMode = 'edit' | 'view' diff --git a/frontend/src/lib/components/sessions/previewReload.test.ts b/frontend/src/lib/components/sessions/previewReload.test.ts index 2cc40cd3cc..c350bb5d47 100644 --- a/frontend/src/lib/components/sessions/previewReload.test.ts +++ b/frontend/src/lib/components/sessions/previewReload.test.ts @@ -98,6 +98,27 @@ describe('tabsToReload', () => { ]) }) + it("reloads the resources list on an agent write but not the agent's editor, which a discard does", () => { + const url = pageItemUrl({ kind: 'resource', path: 'u/me/a' }) + const agentTab: SessionPreviewTab = { id: 'a', url, loc: url } + const reloaded = (name: string, args: any) => { + const { pages, items, live = [] } = toolReloadEffect(name, args) + return tabsToReload( + [resourceTab, agentTab], + new Set(pages), + new Set(items.map(pageItemUrl)), + new Set(live.map(pageItemUrl)) + ) + } + expect(reloaded('write_resource', { resource_type: 'ai_agent', path: 'u/me/a' })).toEqual([ + resourceTab + ]) + expect(reloaded('discard_local_draft', { type: 'resource', path: 'u/me/a' })).toEqual([ + resourceTab, + agentTab + ]) + }) + it('is empty when no pages were touched', () => { expect(tabsToReload(tabs, new Set())).toEqual([]) }) @@ -127,6 +148,19 @@ describe('viewerTabsToReload', () => { ]) }) + it("refreshes an agent's deployed page when the chat deletes or deploys it", () => { + const agentTab: SessionPreviewTab = { + id: 'a', + url: '/agents/get/u/me/bot', + loc: '/agents/get/u/me/bot' + } + const { deployed } = toolReloadEffect('delete_workspace_item', { + type: 'resource', + path: 'u/me/bot' + }) + expect(viewerTabsToReload([agentTab, viewTab], new Set(deployed))).toEqual([agentTab]) + }) + // The editor holds what the user is writing; refetching the deployed version over it // would discard that, and it self-syncs from the store the chat already mutated. it('never returns the edit side, even for the item that was deployed', () => { diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 1de6ad6951..f87d5f65a2 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -6,6 +6,7 @@ import { parsePageItemRoute, parsePreviewItemRoute, barePath, + agentPagePath, TRIGGER_PAGES, type PageItemRef, type TriggerKind @@ -33,7 +34,15 @@ import { // the chat, so a tool that changes what is deployed at a path has to name it here // or that tab keeps showing the previous deployment. Only tools that change the // deployed item belong in `deployed`; a draft write does not. -export type ToolReloadEffect = { pages: string[]; items: PageItemRef[]; deployed: string[] } +// +// `live` names items whose open editor follows this tool's write on its own, so naming them +// reloads their list page but leaves their tab alone. +export type ToolReloadEffect = { + pages: string[] + items: PageItemRef[] + deployed: string[] + live?: PageItemRef[] +} const NO_RELOAD: ToolReloadEffect = { pages: [], items: [], deployed: [] } export function toolReloadEffect(name: string, args: any): ToolReloadEffect { @@ -43,8 +52,13 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect { case 'write_trigger': // Its path sits in the trigger's own config, not beside `kind`. return withItem(triggerPages(args?.kind), itemRef('trigger', args?.config, args?.kind)) - case 'write_resource': - return withItem(['/resources'], itemRef('resource', args)) + case 'write_resource': { + const item = itemRef('resource', args) + const effect = withItem(['/resources'], item) + // An agent's editor follows the chat's writes through the draft cell they share, so + // reloading its tab would only throw away where the reader was in it, a test chat included. + return args?.resource_type === 'ai_agent' && item ? { ...effect, live: [item] } : effect + } case 'write_variable': return withItem(['/variables'], itemRef('variable', args)) case 'create_folder': @@ -77,7 +91,8 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect { // show the deployed side of. Other types (schedule, resource, …) have list pages instead. function deployedItems(args: any): string[] { const type = args?.type - if (type !== 'script' && type !== 'flow' && type !== 'app') return [] + // A resource has no deployed page, except an agent's, which `viewerTabsToReload` matches. + if (type !== 'script' && type !== 'flow' && type !== 'app' && type !== 'resource') return [] return typeof args?.path === 'string' && args.path ? [args.path] : [] } @@ -125,10 +140,12 @@ function triggerPages(kind: unknown): string[] { export function tabsToReload( tabs: SessionPreviewTab[], pages: ReadonlySet<string>, - items: ReadonlySet<string> = new Set() + items: ReadonlySet<string> = new Set(), + live: ReadonlySet<string> = new Set() ): SessionPreviewTab[] { if (pages.size === 0) return [] return tabs.filter((t) => { + if (live.has(t.url)) return false const pageItem = parsePageItemRoute(t.url) if (!pageItem) return pages.has(barePath(whereIs(t))) const listPath = pageItemListPath(pageItem) @@ -147,7 +164,10 @@ export function viewerTabsToReload( ): SessionPreviewTab[] { if (items.size === 0) return [] return tabs.filter((t) => { - const route = parsePreviewItemRoute(whereIs(t)) + const loc = whereIs(t) + const agent = agentPagePath(loc) + if (agent !== undefined) return items.has(agent) + const route = parsePreviewItemRoute(loc) return route?.mode === 'view' && items.has(route.itemPath) }) } diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 9f50621de0..5cbaa542ba 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -19,6 +19,7 @@ import { RUNS_PATH, SCHEDULES_PATH, barePath, + agentPagePath, VARIABLES_PATH, WORKSPACE_SETTINGS_PATH, triggerLabelForPath, @@ -496,6 +497,14 @@ export function previewLocationLabel(url: string): string { if (historical) return `${historical.path.split('/').pop()} @ ${historical.hash.slice(0, 8)}` const parsed = parsePreviewItemRoute(url) if (parsed) return parsed.itemPath.split('/').pop() ?? parsed.itemPath + const agent = barePath(url).match(/^\/agents\/(?:edit|get)\/(.+)$/) + if (agent) { + let path = agent[1] + try { + path = decodeURIComponent(path) + } catch {} + return path.split('/').pop() || path + } return barePath(url) } @@ -628,7 +637,7 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:') // else (static pages, regular drag-and-drop apps, any other route) stays an iframe. export type PreviewSlot = | { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string } - | { kind: 'viewer'; viewerKind: SessionTargetKind; path: string } + | { kind: 'viewer'; viewerKind: SessionTargetKind | 'agent'; path: string } | { kind: 'artifact'; id: string; version?: number } | { kind: 'runform'; toolCallId: string } | { kind: 'pageitem'; ref: PageItemRef } @@ -667,6 +676,9 @@ export function resolvePreviewTab(url: string): PreviewSlot { if (pipelineFolder) { return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder } } + // An agent's deployed page; its editor is the resource tab, which hosts it in process. + const agent = agentPagePath(url) + if (agent !== undefined) return { kind: 'viewer', viewerKind: 'agent', path: agent } const item = parsePreviewItemRoute(url) if (!item || parseHistoricalScriptEdit(url)) return { kind: 'iframe' } const itemKind: SessionTargetKind | undefined = diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index ee13729d4d..368da68421 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -136,6 +136,7 @@ function retargetTab(tab: SessionPreviewTab, url: string): void { tab.friendlyLabel = undefined tab.friendlyPath = undefined tab.editorNamed = undefined + tab.agentEditor = undefined } tab.url = url tab.loc = url @@ -648,6 +649,27 @@ export class SessionPreviewTabs { this.#flush() } + /** Name a tab after what the page in it shows, for a page that is not one of the live editors + * (an agent's deployed page); undefined falls back to the location's label. */ + setTabFriendlyLabel(tabId: string, label: string | undefined): void { + const tab = this.#tabs.find((t) => t.id === tabId) + if (!tab || (tab.editorNamed && tab.friendlyLabel === label)) return + tab.friendlyLabel = label + tab.editorNamed = true + this.#flush() + } + + /** Mark a resource tab as an agent's editor, named after its summary as `setEditorFriendlyLabel` + * names the live editors' tabs; an undefined summary falls back to the path. */ + stampAgentTab(ref: PageItemRef, summary: string | undefined): void { + const tab = this.#tabs.find((t) => t.url === pageItemUrl(ref)) + if (!tab || (tab.agentEditor && tab.editorNamed && tab.friendlyLabel === summary)) return + tab.agentEditor = true + tab.friendlyLabel = summary + tab.editorNamed = true + this.#flush() + } + /** Follow an item its editor deployed under a new path, in place: the editor is keyed * on the path, and nothing is left to edit at the old one. */ retargetEditor(from: SessionTarget, to: SessionTarget): void { diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 8be0b2836e..2e58f3f5b1 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -84,7 +84,8 @@ import { resolvePreviewTab, pendingRunIdOf, slotShowsPage, - type PreviewSlot + type PreviewSlot, + type PreviewTarget } from './previewRouter' import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' import { logFeatureUsage } from '$lib/utils/featureUsage' @@ -484,10 +485,19 @@ function createRuntime(session: Session): SessionRuntime { // to resolve "this page" and "it" against this block, and a collapsed panel would // point those at a page the user cannot see. const tab = owner?.displayedTab - if (!tab) return undefined + if (!tab || tab.agentEditor) return undefined if (!slotShowsPage(resolvePreviewTab(tab.url).kind)) return undefined return previewLocationContext(whereIs(tab)) } + // An agent's editor is a resource tab rather than one of the live editors the draft registry + // reports, so the tab model says when one is on screen. + manager.activeEditorResolver = () => { + const tab = getRuntime(session.id)?.previewTabs?.displayedTab + if (!tab?.agentEditor) return undefined + const slot = resolvePreviewTab(tab.url) + if (slot.kind !== 'pageitem') return undefined + return { type: 'agent', path: slot.ref.path, storagePath: slot.ref.path, isLiveDraft: true } + } // Pre-flight: materialise the (still-transient) session, then commit // the workspace (creating a staged fork if needed) before any send. // AIChatManager awaits this so the first message hits a persisted @@ -652,16 +662,28 @@ function createRuntime(session: Session): SessionRuntime { carries, onlyIfAlreadyOpen }) => { - // Through the shared adapter: a session target spells a code-based app `raw_app` and a - // workspace item spells it `app` with a flag, and one place should know that. - const target = previewTargetForSessionTarget(kind, path) - if (target?.type !== 'item') return false - const viewTarget = { - ...target, - item: { ...target.item, summary }, - mode: 'view' as const, - pendingRunId: carries ? toolCallId : undefined - } + // An agent's deployed page is a page route, which the panel hosts in process; the call + // rides in its query, as an item viewer's does. + const viewTarget: PreviewTarget | undefined = + kind === 'agent' + ? { + type: 'page', + href: `/agents/get/${path}${carries ? `?pending_run=${encodeURIComponent(toolCallId)}` : ''}`, + label: summary + } + : (() => { + // Through the shared adapter: a session target spells a code-based app `raw_app` + // and a workspace item spells it `app` with a flag, and one place should know that. + const target = previewTargetForSessionTarget(kind, path) + if (target?.type !== 'item') return undefined + return { + ...target, + item: { ...target.item, summary }, + mode: 'view' as const, + pendingRunId: carries ? toolCallId : undefined + } + })() + if (!viewTarget) return false // Re-pointed rather than focused: the url is what hands the tab the call, and // rebuilding it also drops whatever version the reader had pinned. const open = previewTabs.tabs.find((t) => { @@ -1526,6 +1548,19 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path, mode }) = if (!session) { return 'Error: no active session to open the preview in.' } + // An agent's editor is its resource tab, and its deployed page the agent's own. + if (kind === 'agent') { + const view = mode === 'view' + const result = getOrCreateRuntime(session).previewTabs.open( + view + ? { type: 'page', href: `/agents/get/${path}`, label: path } + : { type: 'pageitem', ref: { kind: 'resource', path } } + ) + const side = view ? 'deployed page' : 'editor' + return result.status === 'focused' + ? `A preview tab is already showing the ${side} of agent "${path}" — focused it.` + : `Opened the ${side} of agent "${path}" in a new tab in the side panel.` + } const target = previewTargetForSessionTarget( kind, mode === 'view' || kind === 'pipeline' ? path : draftStoragePath(session, kind, path) diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 9f6bb07dd4..5e955924d5 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -199,6 +199,9 @@ export type SessionPreviewTab = { // from the workspace listing; after, the editor is the only source, else // clearing a summary would fall back to the listing's stale copy of it. editorNamed?: boolean + // Stamped by an agent's editor in a resource tab, which is what makes that tab the chat's + // ACTIVE EDITOR rather than a page it previews. + agentEditor?: boolean } // Sessions live in one per-user IndexedDB, one record per session in the diff --git a/frontend/src/lib/components/sessions/workspaceAgents.ts b/frontend/src/lib/components/sessions/workspaceAgents.ts new file mode 100644 index 0000000000..3a3e51ba1f --- /dev/null +++ b/frontend/src/lib/components/sessions/workspaceAgents.ts @@ -0,0 +1,45 @@ +import { DraftService, ResourceService } from '$lib/gen' + +export type WorkspaceAgent = { + path: string + summary: string + draftOnly: boolean + hasDraft: boolean +} + +/** The workspace's agents as a session shows them: the deployed ones, then those that exist only + * as a draft. The draft listing does not say which resources are agents, so those are asked. */ +export async function listWorkspaceAgents(workspace: string): Promise<WorkspaceAgent[]> { + const [deployed, drafts] = await Promise.all([ + ResourceService.listResource({ workspace, resourceType: 'ai_agent', perPage: 1000 }), + // Without drafts the deployed agents still list, unmarked. + DraftService.listDrafts({ workspace }).catch((error) => { + console.error('Failed to list drafts for the workspace agents', error) + return [] + }) + ]) + const resourceDrafts = drafts.filter((d) => d.kind === 'resource') + const drafted = new Set(resourceDrafts.map((d) => d.path)) + const draftOnly = await Promise.all( + resourceDrafts + .filter((d) => d.draft_only) + .map((d) => + ResourceService.getResource({ workspace, path: d.path, getDraft: true }).then( + (r) => + r.resource_type === 'ai_agent' + ? { path: d.path, summary: r.description ?? '', draftOnly: true, hasDraft: true } + : undefined, + () => undefined + ) + ) + ) + return [ + ...deployed.map((r) => ({ + path: r.path, + summary: r.description ?? '', + draftOnly: false, + hasDraft: drafted.has(r.path) + })), + ...draftOnly.filter((a) => a !== undefined) + ] +} diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index 716eaca9d7..cb426cbc67 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -58,6 +58,7 @@ import { saveSqsTriggerFromCfg } from '$lib/components/triggers/sqs/utils' import { saveGcpTriggerFromCfg } from '$lib/components/triggers/gcp/utils' import { saveAzureTriggerFromCfg } from '$lib/components/triggers/azure/utils' import { saveEmailTriggerFromCfg } from '$lib/components/triggers/email/utils' +import { markAgentWritten } from '$lib/components/flows/agentEditorStore.svelte' export type DraftKind = UserDraftItemKind @@ -368,9 +369,14 @@ export async function getDraftDiffValues( throw new Error(`Draft diff not supported for kind ${kind}`) } const { deployed, draft, hasDraft, noDeployed } = splitOverlay(await getter(workspace, path)) + // The agent editor's draft of a deployed agent omits its type, which deploying keeps. + const draftWithType = + kind === 'resource' && draft && !draft.resource_type && deployed?.resource_type + ? { ...draft, resource_type: deployed.resource_type } + : draft return { deployed: draftOnly ? {} : canonicalizeDraftDiffValue(kind, deployed, false), - draft: canonicalizeDraftDiffValue(kind, draft, true), + draft: canonicalizeDraftDiffValue(kind, draftWithType, true), hasDraft, noDeployed } @@ -693,6 +699,9 @@ export async function deployDraft( } }) } + // What shows a deployed agent reads it again on this, wherever the deploy came from. + if (!noop && (d.resource_type ?? deployed?.resource_type) === 'ai_agent') + markAgentWritten(workspace, d.path ?? path) } else if (kind === 'trigger_schedule') { const { draft: d } = splitOverlay(await OVERLAY_GETTERS.trigger_schedule!(workspace, path)) // The schedule editor's draft IS the cfg shape `saveScheduleFromCfg` diff --git a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte index f05203e231..eedb53f17a 100644 --- a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte @@ -1,335 +1,10 @@ <script lang="ts"> import { page } from '$app/state' - import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte' - import { Pane, Splitpanes } from 'svelte-splitpanes' - import { resource } from 'runed' - import { goto } from '$lib/navigation' - import { copilotInfo } from '$lib/aiStore' - import AgentEvalsModal from '$lib/components/flows/content/AgentEvalsModal.svelte' - import AgentUsePanel from '$lib/components/flows/content/AgentUsePanel.svelte' - import { SlackAgentsService } from '$lib/gen' - import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte' - import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte' - import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' - import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' - import ShareModal from '$lib/components/ShareModal.svelte' - import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' - import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte' - import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte' - import RunForm from '$lib/components/RunForm.svelte' - import { Alert, Tab, Tabs } from '$lib/components/common' - import { keepsManagedMemory } from '$lib/components/flows/agentFormFields' - import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions' - import AgentHistory from '$lib/components/flows/AgentHistory.svelte' - import { getDeployUiSettings } from '$lib/components/home/deploy_ui' - import { recentItems } from '$lib/components/home/recentItems.svelte' - import { userStore, workspaceStore } from '$lib/stores' - import { useEditRights } from '$lib/operatorWriteRights' - import { sendUserToast } from '$lib/toast' - - /** - * The deployed agent, as a flow's or a script's page shows theirs: a way to run it, a chat when - * its memory keeps the conversation and the inputs form otherwise, beside what it is. - */ - let path = $derived(page.params.path ?? '') - let ws = $derived($workspaceStore) - - /** Bumped to read the agent again after it was renamed in place. */ - let reloaded = $state(0) - let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined) - let agent = $derived(host?.draftHandle()) - let testPane = $derived(host?.testPaneHandle()) - let config = $derived(agent?.state?.args) - let chatAvailable = $derived(keepsManagedMemory(config?.memory)) - const editRights = useEditRights() - let canAuthor = $derived(editRights.roleCanAuthor('agent')) - let canCode = $derived(editRights.capabilities.code) - let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && canAuthor) - - // Recorded once the agent is in: a path with nothing deployed never loads one. - let loaded = $derived(config != undefined) - $effect(() => { - if (ws && loaded) { - recentItems.record(ws, { - kind: 'agent', - path, - summary: agent?.state?.description, - via: 'view' - }) - } - }) - - let configModal: AgentConfigModal | undefined = $state(undefined) - let shareModal: ShareModal | undefined = $state(undefined) - let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined) - let history: AgentHistory | undefined = $state(undefined) - let deleteOpen = $state(false) - let evalsModal: AgentEvalsModal | undefined = $state(undefined) - let clientWidth = $state(window.innerWidth) - let pickedMobileTab = $state('agent') - // The page is reused across agents, and an agent without managed memory has no Slack tab. - let mobileTab = $derived(pickedMobileTab === 'slack' && !chatAvailable ? 'api' : pickedMobileTab) - let wide = $derived(clientWidth >= 1024) - - const slack = resource( - () => ({ workspace: ws, path }), - async ({ workspace, path }) => - workspace ? await SlackAgentsService.getAgentSlackChannels({ workspace, path }) : undefined - ) - let runForm: RunForm | undefined = $state(undefined) - let isValid = $state(true) - - // Cmd/Ctrl+Enter runs the form, as on a script's or a flow's page. The form stays mounted, hidden, - // while the chat shows, and the chat's composer owns the key then. - function onKeyDown(event: KeyboardEvent) { - if (event.defaultPrevented || event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) return - if (!runForm || testPane?.mode === 'chat' || (!wide && mobileTab !== 'agent')) return - if (isValid) { - event.preventDefault() - runForm.run() - } else { - sendUserToast('Please fix errors before running', true) - } - } - - async function remove() { - if (ws && (await deleteAgent(ws, path))) await goto('/?kind=agent') - } - - // The workspace's, so a path change never waits on it and a menu never outlives its path. - const deployUiSettings = resource( - () => ws, - () => getDeployUiSettings() - ) - // A role that cannot build agents gets no menu, as on a flow's page. - let menuItems = $derived( - config != undefined && canAuthor - ? agentMenuItems({ - path, - canWrite: canEdit, - noCode: !canCode, - wsSpecific: agent?.state?.wsSpecific, - deployUiSettings: deployUiSettings.current, - onPermissions: () => shareModal?.openDrawer?.(path, 'resource'), - onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'), - onHistory: () => history?.open(), - onDelete: () => (deleteOpen = true) - }).map((item) => ({ - label: item.displayName, - Icon: item.icon, - onclick: (e: MouseEvent) => item.action?.(e), - disabled: item.disabled, - color: item.type === 'delete' ? ('red' as const) : undefined - })) - : [] - ) + import AgentDetailPage from '$lib/components/flows/content/AgentDetailPage.svelte' + import { workspaceStore } from '$lib/stores' </script> -<ShareModal bind:this={shareModal} /> -<DeployWorkspaceDrawer bind:this={deploymentDrawer} /> -<AgentHistory bind:this={history} {path} canRestore={canEdit} onRestored={() => reloaded++} /> -<ConfirmationModal - open={deleteOpen} - title="Delete agent" - confirmationText="Delete" - on:canceled={() => (deleteOpen = false)} - on:confirmed={() => { - deleteOpen = false - remove() - }} -> - <span>Every flow that links {path} will fail at its agent step once it is deleted.</span> -</ConfirmationModal> - -<svelte:window onkeydown={onKeyDown} /> - -<main class="h-full w-full flex flex-col"> - <!-- `labels`: the pen saves every field it shows in one write, so the labels it shows have to be - the ones the resource carries. Handed none, it would offer an empty list, and the first - label added there would replace the agent's own. --> - <DetailPageHeader - ownsPageHeader - itemKind="agent" - summary={config ? agent?.state?.description : undefined} - labels={agent?.state?.labels} - {path} - {menuItems} - onSaved={canEdit - ? async (newPath) => { - // A rename moves the page; a summary saved in place has to be read again, since - // what is shown here came from the load this page keys on. - if (newPath !== path) await goto(`/agents/get/${newPath}`) - else reloaded++ - } - : undefined} - mainButtons={[ - // Evaluating an agent builds datasets and runs against it: authoring, as editing is, and - // eval datasets are refused to a role without `code` whatever its flows right. - ...(canEdit && canCode - ? [ - { - label: 'Evals', - buttonProps: { - variant: 'default', - unifiedSize: 'md', - startIcon: FlaskConical, - title: 'Run this agent against a dataset of cases', - onClick: () => evalsModal?.openModal() - } - } - ] - : []), - ...(canEdit - ? [ - { - label: 'Edit', - buttonProps: { - variant: 'accent', - unifiedSize: 'md', - startIcon: Pen, - href: `/agents/edit/${path}` - } - } - ] - : []) - ]} - > - <OnBehalfOfBadge - onBehalfOf={config?.run_on_behalf_of ? agent?.onBehalfOf : undefined} - onBehalfOfEmail={undefined} - kind="agent" - /> - {#snippet leading_actions()} - <!-- Only an agent that keeps the conversation can chat: without managed memory every - message would be answered alone, so it is run from its inputs with nothing to switch. --> - {#if chatAvailable && testPane?.mode} - <ToggleButtonGroup - bind:selected={ - () => testPane?.mode, - (mode) => { - if (testPane) testPane.mode = mode - } - } - noWFull - > - {#snippet children({ item })} - <ToggleButton - size="md" - value="chat" - label="Chat" - icon={MessageSquare} - tooltip="Chat with the agent: each message runs it, and it remembers the conversation" - {item} - /> - <ToggleButton - size="md" - value="form" - label="Form" - icon={FormInput} - tooltip="Run the agent once, on the inputs in a form" - {item} - /> - {/snippet} - </ToggleButtonGroup> - {/if} - {/snippet} - </DetailPageHeader> - <!-- Below `lg` the split would squeeze the chat, so the panel's sections become tabs of the - page, as on a script's or a flow's page. They cover the agent rather than replacing it: - unmounting the host destroys the chat and any turn in flight, so the agent pane keeps one - parent at every width. --> - {#if !wide && ws && config} - <Tabs - bind:selected={() => mobileTab, (t) => (pickedMobileTab = t)} - wrapperClass="flex-none no-scrollbar" - > - <Tab value="agent" label={testPane?.mode === 'chat' ? 'Chat' : 'Run form'} /> - <Tab value="api" label="API" /> - {#if chatAvailable} - <Tab value="slack" label="Slack" /> - {/if} - </Tabs> - {/if} - <div class="relative flex-1 min-h-0" bind:clientWidth> - <Splitpanes> - <Pane size={wide ? 65 : 100} minSize={50}> - <div class="h-full" inert={!wide && mobileTab !== 'agent'}> - {@render agentPane()} - </div> - </Pane> - {#if wide && ws && config} - <Pane size={35} minSize={15}> - {@render usePane(ws)} - </Pane> - {/if} - </Splitpanes> - {#if !wide && ws && config} - <div class={['absolute inset-0 z-10 bg-surface', mobileTab === 'agent' && 'hidden']}> - {@render usePane(ws, mobileTab === 'slack' ? 'slack' : 'api')} - </div> - {/if} - </div> -</main> - -{#snippet agentPane()} - {#key `${ws}:${path}:${reloaded}`} - <AgentEditorHost - bind:this={host} - {path} - workspace={ws} - enableAi={$copilotInfo.enabled} - view - onOpenConfig={() => configModal?.open()} - > - {#snippet viewForm({ schema, run, loading, actions })} - {#if config && !chatAvailable} - <Alert type="info" size="xs" title="Managed memory is off" class="mb-4"> - Each run is answered on its own inputs, so this agent has no chat. That suits reusing it - as a step in a flow.{canEdit - ? ' Turn on managed memory in its configuration to chat with it.' - : ''} - </Alert> - {/if} - <RunForm - runnable={{ schema, path }} - runAction={run} - schedulable={false} - detailed={false} - autofocus - {loading} - {actions} - bind:isValid - bind:this={runForm} - /> - {/snippet} - </AgentEditorHost> - {/key} -{/snippet} - -{#snippet usePane(ws: string, section?: 'api' | 'slack')} - <AgentUsePanel - agentPath={path} - workspace={ws} - isAdmin={!!($userStore?.is_admin || $userStore?.is_super_admin)} - chat={chatAvailable} - slackLoading={slack.loading && !slack.current} - slackTeamName={slack.current?.slack_team_name} - botName={slack.current?.bot_name} - missingScopes={slack.current?.missing_scopes ?? []} - signingSecretSet={slack.current?.signing_secret_set ?? true} - runsAs={slack.current?.runs_as} - anyone={slack.current?.anyone ?? false} - canShare={slack.current?.can_share ?? false} - channels={slack.current?.channels ?? []} - {section} - /> -{/snippet} - -{#if ws} - <AgentEvalsModal bind:this={evalsModal} agentPath={path} workspace={ws} /> -{/if} - -{#if config} - <AgentConfigModal bind:this={configModal} {config} toolSchema={(id) => host?.toolSchema(id)} /> -{/if} +<!-- Keyed: the route is reused across agents, and the page holds the form's args. --> +{#key `${$workspaceStore}:${page.params.path}`} + <AgentDetailPage path={page.params.path ?? ''} workspace={$workspaceStore} /> +{/key} diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 19e7b60462..f0b866b373 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -94,7 +94,12 @@ tabsToReload, viewerTabsToReload } from '$lib/components/sessions/previewReload' - import { isPreviewListPath, barePath } from '$lib/components/sessions/previewPaths' + import { + isPreviewListPath, + barePath, + agentPagePath + } from '$lib/components/sessions/previewPaths' + import { listWorkspaceAgents } from '$lib/components/sessions/workspaceAgents' import { leafKeyFor, loadKind, @@ -436,8 +441,12 @@ * otherwise be two tabs with the same name. Only the editor is marked: the deployed * page is the item itself, and a page, artifact or pipeline has no second side to be * told apart from. */ - function sideSuffix(url: string): string { - const slot = resolvePreviewTab(url) + function sideSuffix(tab: SessionPreviewTab, workspace: string): string { + // An agent's editor is a resource tab, beside the agent's own deployed page. + if (tab.agentEditor) return ' (edit)' + if (parsePageItemRoute(tab.loc) && listedAgentSummary(tab, workspace) !== undefined) + return ' (edit)' + const slot = resolvePreviewTab(tab.url) return slot.kind === 'editor' && slot.editorKind !== 'pipeline' ? ' (edit)' : '' } let newTabOpen = $state(false) @@ -658,7 +667,16 @@ // and the item paths whose deployed version changed. By workspace because a path names an // item only within one: a write in one fork must not remount the same path's editor in a // session on another. - let pending = new Map<string, { pages: Set<string>; items: Set<string>; deployed: Set<string> }>() + // `live` holds the items a write left to their editor; `forced` the ones another tool in the + // same round needs reloaded all the same (a discard after a write), which wins. + type ReloadScope = { + pages: Set<string> + items: Set<string> + deployed: Set<string> + live: Set<string> + forced: Set<string> + } + let pending = new Map<string, ReloadScope>() // Reload the mounted tabs a chat round changed in each warm session acting on that // workspace (a hidden preview would otherwise show pre-mutation content on return). @@ -670,7 +688,12 @@ const owner = getRuntime(s.id)?.previewTabs if (!scope || !owner) continue const affected = [ - ...tabsToReload(owner.tabs, scope.pages, scope.items), + ...tabsToReload( + owner.tabs, + scope.pages, + scope.items, + new Set([...scope.live].filter((u) => !scope.forced.has(u))) + ), ...viewerTabsToReload(owner.tabs, scope.deployed) ] for (const tab of affected) { @@ -682,16 +705,28 @@ $effect(() => { // Debounced so a burst of writes (the AI editing several files) reloads once. setToolCompletionListener((name, args, workspace) => { - const { pages, items, deployed } = toolReloadEffect(name, args) + const { pages, items, deployed, live = [] } = toolReloadEffect(name, args) if (pages.length === 0 && deployed.length === 0) return let scope = pending.get(workspace) if (!scope) pending.set( workspace, - (scope = { pages: new Set(), items: new Set(), deployed: new Set() }) + (scope = { + pages: new Set(), + items: new Set(), + deployed: new Set(), + live: new Set(), + forced: new Set() + }) ) + const liveUrls = new Set(live.map(pageItemUrl)) for (const p of pages) scope.pages.add(p) - for (const item of items) scope.items.add(pageItemUrl(item)) + for (const item of items) { + const url = pageItemUrl(item) + scope.items.add(url) + if (liveUrls.has(url)) scope.live.add(url) + else scope.forced.add(url) + } for (const d of deployed) scope.deployed.add(d) clearTimeout(reloadHandle) reloadHandle = setTimeout(flushReload, 500) @@ -721,6 +756,7 @@ $effect(() => { return registerToolDisplayActionHandler('open_created_resource', (action) => { if (action.type !== 'open_created_resource') return + // An agent's resource tab renders the agent's own editor. const ref: PageItemRef | undefined = action.resource === 'trigger' ? action.triggerKind && { @@ -728,7 +764,7 @@ triggerKind: action.triggerKind, path: action.path } - : { kind: action.resource, path: action.path } + : { kind: action.resource === 'agent' ? 'resource' : action.resource, path: action.path } if (ref) owner?.open({ type: 'pageitem', ref }) }) }) @@ -834,6 +870,40 @@ } }) + // The same for agents, whose tabs are an agent's deployed page or a resource tab its + // editor took over: summaries by path, so an unvisited one is named and marked as the + // editor side before its editor stamps it. + const agentSummariesRequested = new Set<string>() + let agentSummaries = $state<Record<string, Record<string, string>>>({}) + $effect(() => { + const ws = previewWorkspace + if (!ws || agentSummariesRequested.has(ws)) return + if (!(owner?.tabs ?? []).some((t) => agentTabPath(t) !== undefined)) return + agentSummariesRequested.add(ws) + listWorkspaceAgents(ws) + .then((agents) => { + agentSummaries = { + ...agentSummaries, + [ws]: Object.fromEntries(agents.map((a) => [a.path, a.summary])) + } + }) + .catch((e) => { + agentSummariesRequested.delete(ws) + console.error('Failed to load workspace agents', e) + }) + }) + function agentTabPath(tab: SessionPreviewTab): string | undefined { + const get = agentPagePath(tab.loc) + if (get !== undefined) return get + const ref = parsePageItemRoute(tab.loc) + return ref?.kind === 'resource' ? ref.path : undefined + } + function listedAgentSummary(tab: SessionPreviewTab, workspace: string): string | undefined { + if (tab.editorNamed) return undefined + const path = agentTabPath(tab) + return path === undefined ? undefined : agentSummaries[workspace]?.[path] + } + // Keyed by the tab's OWN session workspace, never the active one: every warm // session's tabs are labelled here, and two sessions on different forks can // hold the same item path. @@ -857,11 +927,12 @@ return ( tab.friendlyLabel ?? (listed && itemDisplayName(listed.path, listed.draftPath, listed.summary)) ?? + (listedAgentSummary(tab, workspace) || undefined) ?? previewLocationLabel(tab.loc) ) } function tabLabelFor(tab: SessionPreviewTab, workspace: string): string { - return tabNameFor(tab, workspace) + sideSuffix(tab.url) + return tabNameFor(tab, workspace) + sideSuffix(tab, workspace) } // Hover title for a tab. A summary label is free text the strip truncates, and diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index 4ddf084d34..f7be020352 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -67,11 +67,12 @@ value: ## AI Agent Modules 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, `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. +object with an extra `value.tool_type`: `flowmodule` for a script or inline script tool (or a nested +agent or AI decision), `mcp` for an MCP server 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 { @@ -98,7 +99,7 @@ tool to have a person approve or reject each of its calls before it runs. "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { return ['doc1', 'doc2']; }", - "input_transforms": { "query": { "type": "static", "value": "" } } + "input_transforms": { "query": { "type": "ai" } } } } ] @@ -106,6 +107,11 @@ tool to have a person approve or reject each of its calls before it runs. } ``` +- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script + that runs the flow +- Each argument of a tool is either the model's or fixed. `{ "type": "ai" }` (or no value) lets the + model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix + what the model must not choose, such as a resource, a table name or a limit - `provider` is an object, not a bare resource string: `{ "kind": <provider kind>, "resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its diff --git a/system_prompts/auto-generated/index.d.ts b/system_prompts/auto-generated/index.d.ts index fee83d38b5..c989610ebf 100644 --- a/system_prompts/auto-generated/index.d.ts +++ b/system_prompts/auto-generated/index.d.ts @@ -2,6 +2,7 @@ export * from './prompts'; export declare function getScriptPrompt(language: string): string; export declare function getFlowPrompt(): string; export declare function getResourcePrompt(): string; +export declare function getAgentPrompt(): string; export declare function getRawAppPrompt(language?: string): string; export declare function getPipelinePrompt(): string; export declare function getDatatablePrompt(): string; diff --git a/system_prompts/auto-generated/index.ts b/system_prompts/auto-generated/index.ts index da3b622046..f9e890ebcb 100644 --- a/system_prompts/auto-generated/index.ts +++ b/system_prompts/auto-generated/index.ts @@ -51,6 +51,13 @@ export function getResourcePrompt(): string { ].filter(Boolean).join('\n\n'); } +// Helper for AI agent authoring +export function getAgentPrompt(): string { + return [ + prompts.AGENT_BASE + ].filter(Boolean).join('\n\n'); +} + // Helper for raw app authoring (chat consumers). Inline backend runnables are // ordinary Windmill jobs, so the reference has to carry the SDK the runnable // calls — without it an agent invents client functions and hand-rolls HTTP. diff --git a/system_prompts/auto-generated/prompts.d.ts b/system_prompts/auto-generated/prompts.d.ts index 5cb7e336b3..79921ddaa3 100644 --- a/system_prompts/auto-generated/prompts.d.ts +++ b/system_prompts/auto-generated/prompts.d.ts @@ -3,6 +3,7 @@ export declare const SCRIPT_BASE: string; export declare const FLOW_BASE: string; export declare const RESOURCES_BASE: string; +export declare const AGENT_BASE: string; export declare const RAW_APP_BASE: string; export declare const PIPELINE_BASE: string; export declare const DATATABLE_BASE: string; diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index da2c4922c7..95675179d3 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -96,11 +96,12 @@ value: ## AI Agent Modules 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, \`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. +object with an extra \`value.tool_type\`: \`flowmodule\` for a script or inline script tool (or a nested +agent or AI decision), \`mcp\` for an MCP server 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 { @@ -127,7 +128,7 @@ tool to have a person approve or reject each of its calls before it runs. "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { return ['doc1', 'doc2']; }", - "input_transforms": { "query": { "type": "static", "value": "" } } + "input_transforms": { "query": { "type": "ai" } } } } ] @@ -135,6 +136,11 @@ tool to have a person approve or reject each of its calls before it runs. } \`\`\` +- A flow cannot be a tool: a run offered one fails with \`Unsupported tool\`. Wrap it in a script + that runs the flow +- Each argument of a tool is either the model's or fixed. \`{ "type": "ai" }\` (or no value) lets the + model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix + what the model must not choose, such as a resource, a table name or a limit - \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>, "resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its @@ -921,6 +927,117 @@ def main(db: postgresql): `; +export const AGENT_BASE = `# Windmill AI Agents + +An agent, as the Agents page lists it, is a saved LLM with its own system prompt, tools and +memory, stored as a resource of type \`ai_agent\`. It is built once and used in several places: it is +run from its own page (a chat, or a run form), a flow's AI agent step links to it by path, and it +can be shared in Slack channels from its page. Build a standalone agent when the user wants an +assistant they talk to or reuse. Build a flow when the agent is one step of a larger process. + +## Structure + +\`\`\`json +{ + "resource_type": "ai_agent", + "description": "Answers support questions from the product docs", + "value": { + "provider": { "kind": "openai", "resource": "$res:f/ai_providers/openai", "model": "gpt-4o" }, + "system_prompt": "You are the support assistant for Acme. Answer from the docs; say so when they don't cover a question.", + "memory": { "kind": "compaction" }, + "output_type": "text", + "tools": [ + { + "id": "search_docs", + "summary": "search_documentation", + "description": "Search the product documentation. Use it whenever the user asks how a feature works.", + "value": { + "tool_type": "flowmodule", + "type": "script", + "path": "f/support/search_docs", + "input_transforms": { + "query": { "type": "ai" }, + "index": { "type": "static", "value": "product_docs" } + } + } + } + ] + } +} +\`\`\` + +The agent's own settings are plain values, not the \`input_transforms\` an agent step in a flow uses: +each is fixed when the agent is saved. Only a tool's arguments keep \`input_transforms\` (see Tools). \`$res:\` and \`$var:\` references inside it are resolved at run time. + +- \`provider\` (required): \`{ "kind", "resource": "$res:<path>", "model" }\`, plus an optional + \`reasoning_effort\`. \`resource\` names the workspace's AI provider resource of that kind +- \`system_prompt\`: how the agent behaves. Write it for the agent's job: who it serves, what it may + do with its tools, and what it should refuse or hand back +- \`memory\`: what lets the agent see earlier turns. \`{ "kind": "compaction" }\` keeps the whole + conversation, summarizing its older part as it nears the model's context window; it is what a new + agent starts with on its page. \`{ "kind": "off" }\` makes every run start from nothing, right for an + agent that handles one request per run +- \`output_type\`: \`text\` (default) or \`image\`. Image output ignores tools and needs S3 storage on the + workspace +- \`output_schema\`: a JSON schema the answer must follow, for an agent whose output a program reads +- \`max_iterations\`: how many model and tool rounds a run may take, 1 to 1000 (default 10) +- \`streaming\` (default on), \`temperature\`, \`max_completion_tokens\`: as their names say +- \`run_on_behalf_of\`: runs from the agent's page take the identity the agent was deployed with + rather than the caller's, so people can use it without access to what its tools touch. + \`slack_anyone\` lets anyone in the Slack channels it is shared in ask it, and is kept only with + \`run_on_behalf_of\`. Set either only when the user asks: both widen who acts with the deployer's + rights +- \`on_behalf_of\` is the identity itself. Deploying sets it, so never write it + +## Tools + +\`tools\` takes the same entries as a flow's AI agent step. \`value.tool_type\` is \`flowmodule\` for a +script or inline script the agent calls (or a nested agent or AI decision), \`mcp\` for an MCP server +(\`{ "tool_type": "mcp", "resource_path": "$res:<path>", "include_tools": [], "exclude_tools": [] }\`), +or \`websearch\` for web search (\`{ "tool_type": "websearch" }\`, summary \`Web Search\`). + +- Write logic made for this agent as an inline \`rawscript\` tool, as a flow writes its steps. Point a + \`script\` tool at a deployed script (\`path\`) only when one already does the job: a run calls the + deployed version, so a script that exists only as a draft fails the run. An inline tool's + \`value\` is a flow's rawscript step: + \`{ "tool_type": "flowmodule", "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { ... }", "input_transforms": { "query": { "type": "ai" } } }\` +- A flow cannot be a tool: a run offered one fails with \`Unsupported tool\`. Wrap it in a script + that runs the flow +- A flowmodule tool's \`summary\` is the name the agent calls it by: letters, numbers and underscores + only, unique among the agent's tools, and neither a reserved step id (a JavaScript keyword, \`ctx\`, + \`state\`, \`bg\`) nor \`__wm_web_search\`. Put the explanation in \`description\`, which tells the agent + when to call it +- Each argument of a tool is either the model's or fixed. \`{ "type": "ai" }\` (or no value) lets the + model fill it; a static value fixes it and hides it from the model. Fix what the model must not + choose, such as a resource, a table name or a limit + +## Using It From a Flow + +An AI agent step links to a saved agent with \`value.agent\` instead of carrying its own \`provider\`, +system prompt and tools: + +\`\`\`json +{ + "id": "support", + "value": { + "type": "aiagent", + "agent": "f/support/support_agent", + "input_transforms": { + "user_message": { "type": "javascript", "expr": "flow_input.question" } + }, + "tools": [] + } +} +\`\`\` + +The step holds only what belongs to one use of the agent: \`user_message\`, \`user_attachments\`, +\`enabled_tools\`, \`memory_id\`, \`previous_messages\`, and the \`model\` and \`reasoning_effort\` overrides. +Its \`value.tool_inputs\` (\`{ "<tool id>": { "<argument>": <input transform> } }\`) wires a saved +tool's arguments to this flow, e.g. \`flow_input.customer_id\`, over the transforms the agent saved, +without changing the agent. Everything else comes from the agent, so a change to the agent reaches +every flow linking it. +`; + export const RAW_APP_BASE = `# Windmill Raw Apps Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables. diff --git a/system_prompts/auto-generated/skills/agents/SKILL.md b/system_prompts/auto-generated/skills/agents/SKILL.md new file mode 100644 index 0000000000..17e7e0af61 --- /dev/null +++ b/system_prompts/auto-generated/skills/agents/SKILL.md @@ -0,0 +1,119 @@ +--- +name: agents +description: MUST use when creating or changing an AI agent, the reusable `ai_agent` resource the Agents page lists. +--- + +# Windmill AI Agents + +An agent, as the Agents page lists it, is a saved LLM with its own system prompt, tools and +memory, stored as a resource of type `ai_agent`. It is built once and used in several places: it is +run from its own page (a chat, or a run form), a flow's AI agent step links to it by path, and it +can be shared in Slack channels from its page. Build a standalone agent when the user wants an +assistant they talk to or reuse. Build a flow when the agent is one step of a larger process. + +## File Format + +An agent is a resource file like any other, `{path}.resource.yaml` (or `.resource.json`), with +`resource_type: ai_agent`. + +## Structure + +```json +{ + "resource_type": "ai_agent", + "description": "Answers support questions from the product docs", + "value": { + "provider": { "kind": "openai", "resource": "$res:f/ai_providers/openai", "model": "gpt-4o" }, + "system_prompt": "You are the support assistant for Acme. Answer from the docs; say so when they don't cover a question.", + "memory": { "kind": "compaction" }, + "output_type": "text", + "tools": [ + { + "id": "search_docs", + "summary": "search_documentation", + "description": "Search the product documentation. Use it whenever the user asks how a feature works.", + "value": { + "tool_type": "flowmodule", + "type": "script", + "path": "f/support/search_docs", + "input_transforms": { + "query": { "type": "ai" }, + "index": { "type": "static", "value": "product_docs" } + } + } + } + ] + } +} +``` + +The agent's own settings are plain values, not the `input_transforms` an agent step in a flow uses: +each is fixed when the agent is saved. Only a tool's arguments keep `input_transforms` (see Tools). `$res:` and `$var:` references inside it are resolved at run time. + +- `provider` (required): `{ "kind", "resource": "$res:<path>", "model" }`, plus an optional + `reasoning_effort`. `resource` names the workspace's AI provider resource of that kind +- `system_prompt`: how the agent behaves. Write it for the agent's job: who it serves, what it may + do with its tools, and what it should refuse or hand back +- `memory`: what lets the agent see earlier turns. `{ "kind": "compaction" }` keeps the whole + conversation, summarizing its older part as it nears the model's context window; it is what a new + agent starts with on its page. `{ "kind": "off" }` makes every run start from nothing, right for an + agent that handles one request per run +- `output_type`: `text` (default) or `image`. Image output ignores tools and needs S3 storage on the + workspace +- `output_schema`: a JSON schema the answer must follow, for an agent whose output a program reads +- `max_iterations`: how many model and tool rounds a run may take, 1 to 1000 (default 10) +- `streaming` (default on), `temperature`, `max_completion_tokens`: as their names say +- `run_on_behalf_of`: runs from the agent's page take the identity the agent was deployed with + rather than the caller's, so people can use it without access to what its tools touch. + `slack_anyone` lets anyone in the Slack channels it is shared in ask it, and is kept only with + `run_on_behalf_of`. Set either only when the user asks: both widen who acts with the deployer's + rights +- `on_behalf_of` is the identity itself. Deploying sets it, so never write it + +## Tools + +`tools` takes the same entries as a flow's AI agent step. `value.tool_type` is `flowmodule` for a +script or inline script the agent calls (or a nested agent or AI decision), `mcp` for an MCP server +(`{ "tool_type": "mcp", "resource_path": "$res:<path>", "include_tools": [], "exclude_tools": [] }`), +or `websearch` for web search (`{ "tool_type": "websearch" }`, summary `Web Search`). + +- Write logic made for this agent as an inline `rawscript` tool, as a flow writes its steps. Point a + `script` tool at a deployed script (`path`) only when one already does the job: a run calls the + deployed version, so a script that exists only as a draft fails the run. An inline tool's + `value` is a flow's rawscript step: + `{ "tool_type": "flowmodule", "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { ... }", "input_transforms": { "query": { "type": "ai" } } }` +- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script + that runs the flow +- A flowmodule tool's `summary` is the name the agent calls it by: letters, numbers and underscores + only, unique among the agent's tools, and neither a reserved step id (a JavaScript keyword, `ctx`, + `state`, `bg`) nor `__wm_web_search`. Put the explanation in `description`, which tells the agent + when to call it +- Each argument of a tool is either the model's or fixed. `{ "type": "ai" }` (or no value) lets the + model fill it; a static value fixes it and hides it from the model. Fix what the model must not + choose, such as a resource, a table name or a limit + +## Using It From a Flow + +An AI agent step links to a saved agent with `value.agent` instead of carrying its own `provider`, +system prompt and tools: + +```json +{ + "id": "support", + "value": { + "type": "aiagent", + "agent": "f/support/support_agent", + "input_transforms": { + "user_message": { "type": "javascript", "expr": "flow_input.question" } + }, + "tools": [] + } +} +``` + +The step holds only what belongs to one use of the agent: `user_message`, `user_attachments`, +`enabled_tools`, `memory_id`, `previous_messages`, and the `model` and `reasoning_effort` overrides. +Its `value.tool_inputs` (`{ "<tool id>": { "<argument>": <input transform> } }`) wires a saved +tool's arguments to this flow, e.g. `flow_input.customer_id`, over the transforms the agent saved, +without changing the agent. Everything else comes from the agent, so a change to the agent reaches +every flow linking it. diff --git a/system_prompts/auto-generated/skills/resources/SKILL.md b/system_prompts/auto-generated/skills/resources/SKILL.md index 45d3c64619..1e313a2d90 100644 --- a/system_prompts/auto-generated/skills/resources/SKILL.md +++ b/system_prompts/auto-generated/skills/resources/SKILL.md @@ -39,6 +39,7 @@ Example: `f/databases/postgres_prod.resource.json` An agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the agent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by linking its path. +Before writing one, use the `agents` skill. ## Variable References diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 8308f33c6b..fbada8732d 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -154,11 +154,12 @@ value: ## AI Agent Modules 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, `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. +object with an extra `value.tool_type`: `flowmodule` for a script or inline script tool (or a nested +agent or AI decision), `mcp` for an MCP server 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 { @@ -185,7 +186,7 @@ tool to have a person approve or reject each of its calls before it runs. "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { return ['doc1', 'doc2']; }", - "input_transforms": { "query": { "type": "static", "value": "" } } + "input_transforms": { "query": { "type": "ai" } } } } ] @@ -193,6 +194,11 @@ tool to have a person approve or reject each of its calls before it runs. } ``` +- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script + that runs the flow +- Each argument of a tool is either the model's or fixed. `{ "type": "ai" }` (or no value) lets the + model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix + what the model must not choose, such as a resource, a table name or a limit - `provider` is an object, not a bare resource string: `{ "kind": <provider kind>, "resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its diff --git a/system_prompts/base/agent.md b/system_prompts/base/agent.md new file mode 100644 index 0000000000..ff1af1123e --- /dev/null +++ b/system_prompts/base/agent.md @@ -0,0 +1,116 @@ +# Windmill AI Agents + +An agent, as the Agents page lists it, is a saved LLM with its own system prompt, tools and +memory, stored as a resource of type `ai_agent`. It is built once and used in several places: it is +run from its own page (a chat, or a run form), a flow's AI agent step links to it by path, and it +can be shared in Slack channels from its page. Build a standalone agent when the user wants an +assistant they talk to or reuse. Build a flow when the agent is one step of a larger process. + +<!-- cli-only --> +## File Format + +An agent is a resource file like any other, `{path}.resource.yaml` (or `.resource.json`), with +`resource_type: ai_agent`. +<!-- /cli-only --> + +## Structure + +```json +{ + "resource_type": "ai_agent", + "description": "Answers support questions from the product docs", + "value": { + "provider": { "kind": "openai", "resource": "$res:f/ai_providers/openai", "model": "gpt-4o" }, + "system_prompt": "You are the support assistant for Acme. Answer from the docs; say so when they don't cover a question.", + "memory": { "kind": "compaction" }, + "output_type": "text", + "tools": [ + { + "id": "search_docs", + "summary": "search_documentation", + "description": "Search the product documentation. Use it whenever the user asks how a feature works.", + "value": { + "tool_type": "flowmodule", + "type": "script", + "path": "f/support/search_docs", + "input_transforms": { + "query": { "type": "ai" }, + "index": { "type": "static", "value": "product_docs" } + } + } + } + ] + } +} +``` + +The agent's own settings are plain values, not the `input_transforms` an agent step in a flow uses: +each is fixed when the agent is saved. Only a tool's arguments keep `input_transforms` (see Tools). `$res:` and `$var:` references inside it are resolved at run time. + +- `provider` (required): `{ "kind", "resource": "$res:<path>", "model" }`, plus an optional + `reasoning_effort`. `resource` names the workspace's AI provider resource of that kind +- `system_prompt`: how the agent behaves. Write it for the agent's job: who it serves, what it may + do with its tools, and what it should refuse or hand back +- `memory`: what lets the agent see earlier turns. `{ "kind": "compaction" }` keeps the whole + conversation, summarizing its older part as it nears the model's context window; it is what a new + agent starts with on its page. `{ "kind": "off" }` makes every run start from nothing, right for an + agent that handles one request per run +- `output_type`: `text` (default) or `image`. Image output ignores tools and needs S3 storage on the + workspace +- `output_schema`: a JSON schema the answer must follow, for an agent whose output a program reads +- `max_iterations`: how many model and tool rounds a run may take, 1 to 1000 (default 10) +- `streaming` (default on), `temperature`, `max_completion_tokens`: as their names say +- `run_on_behalf_of`: runs from the agent's page take the identity the agent was deployed with + rather than the caller's, so people can use it without access to what its tools touch. + `slack_anyone` lets anyone in the Slack channels it is shared in ask it, and is kept only with + `run_on_behalf_of`. Set either only when the user asks: both widen who acts with the deployer's + rights +- `on_behalf_of` is the identity itself. Deploying sets it, so never write it + +## Tools + +`tools` takes the same entries as a flow's AI agent step. `value.tool_type` is `flowmodule` for a +script or inline script the agent calls (or a nested agent or AI decision), `mcp` for an MCP server +(`{ "tool_type": "mcp", "resource_path": "$res:<path>", "include_tools": [], "exclude_tools": [] }`), +or `websearch` for web search (`{ "tool_type": "websearch" }`, summary `Web Search`). + +- Write logic made for this agent as an inline `rawscript` tool, as a flow writes its steps. Point a + `script` tool at a deployed script (`path`) only when one already does the job: a run calls the + deployed version, so a script that exists only as a draft fails the run. An inline tool's + `value` is a flow's rawscript step: + `{ "tool_type": "flowmodule", "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { ... }", "input_transforms": { "query": { "type": "ai" } } }` +- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script + that runs the flow +- A flowmodule tool's `summary` is the name the agent calls it by: letters, numbers and underscores + only, unique among the agent's tools, and neither a reserved step id (a JavaScript keyword, `ctx`, + `state`, `bg`) nor `__wm_web_search`. Put the explanation in `description`, which tells the agent + when to call it +- Each argument of a tool is either the model's or fixed. `{ "type": "ai" }` (or no value) lets the + model fill it; a static value fixes it and hides it from the model. Fix what the model must not + choose, such as a resource, a table name or a limit + +## Using It From a Flow + +An AI agent step links to a saved agent with `value.agent` instead of carrying its own `provider`, +system prompt and tools: + +```json +{ + "id": "support", + "value": { + "type": "aiagent", + "agent": "f/support/support_agent", + "input_transforms": { + "user_message": { "type": "javascript", "expr": "flow_input.question" } + }, + "tools": [] + } +} +``` + +The step holds only what belongs to one use of the agent: `user_message`, `user_attachments`, +`enabled_tools`, `memory_id`, `previous_messages`, and the `model` and `reasoning_effort` overrides. +Its `value.tool_inputs` (`{ "<tool id>": { "<argument>": <input transform> } }`) wires a saved +tool's arguments to this flow, e.g. `flow_input.customer_id`, over the transforms the agent saved, +without changing the agent. Everything else comes from the agent, so a change to the agent reaches +every flow linking it. diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index bc5542145e..c8aa213d10 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -67,11 +67,12 @@ value: ## AI Agent Modules 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, `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. +object with an extra `value.tool_type`: `flowmodule` for a script or inline script tool (or a nested +agent or AI decision), `mcp` for an MCP server 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 { @@ -98,7 +99,7 @@ tool to have a person approve or reject each of its calls before it runs. "type": "rawscript", "language": "bun", "content": "export async function main(query: string) { return ['doc1', 'doc2']; }", - "input_transforms": { "query": { "type": "static", "value": "" } } + "input_transforms": { "query": { "type": "ai" } } } } ] @@ -106,6 +107,11 @@ tool to have a person approve or reject each of its calls before it runs. } ``` +- A flow cannot be a tool: a run offered one fails with `Unsupported tool`. Wrap it in a script + that runs the flow +- Each argument of a tool is either the model's or fixed. `{ "type": "ai" }` (or no value) lets the + model fill it; a static value, an empty string included, fixes it and hides it from the model. Fix + what the model must not choose, such as a resource, a table name or a limit - `provider` is an object, not a bare resource string: `{ "kind": <provider kind>, "resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its diff --git a/system_prompts/base/resources.md b/system_prompts/base/resources.md index 652bec1964..f9f0c2c68d 100644 --- a/system_prompts/base/resources.md +++ b/system_prompts/base/resources.md @@ -36,6 +36,9 @@ Example: `f/databases/postgres_prod.resource.json` An agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the agent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by linking its path. +<!-- cli-only --> +Before writing one, use the `agents` skill. +<!-- /cli-only --> ## Variable References diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 7249954d01..0a76d6045d 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -1683,6 +1683,12 @@ TOPICS: dict[str, dict] = { 'chat_helper': 'getResourcePrompt', 'parts': ['resources.md'], }, + 'agent': { + 'skill': 'agents', + 'description': 'MUST use when creating or changing an AI agent, the reusable `ai_agent` resource the Agents page lists.', + 'chat_helper': 'getAgentPrompt', + 'parts': ['agent.md'], + }, 'workflow_as_code': { 'skill': 'write-workflow-as-code', 'description': 'MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow.', @@ -1725,6 +1731,7 @@ CHAT_CONSTANTS = { 'script-base.md': 'SCRIPT_BASE', 'flow-base.md': 'FLOW_BASE', 'resources.md': 'RESOURCES_BASE', + 'agent.md': 'AGENT_BASE', 'raw-app.md': 'RAW_APP_BASE', 'pipeline-base.md': 'PIPELINE_BASE', 'datatable.md': 'DATATABLE_BASE', @@ -2647,6 +2654,11 @@ export function getResourcePrompt(): string { __RETURN_resources__ } +// Helper for AI agent authoring +export function getAgentPrompt(): string { + __RETURN_agent__ +} + // Helper for raw app authoring (chat consumers). Inline backend runnables are // ordinary Windmill jobs, so the reference has to carry the SDK the runnable // calls — without it an agent invents client functions and hand-rolls HTTP. @@ -2733,6 +2745,7 @@ export function getWorkflowAsCodePrompt(language?: string): string { export declare function getScriptPrompt(language: string): string; export declare function getFlowPrompt(): string; export declare function getResourcePrompt(): string; +export declare function getAgentPrompt(): string; export declare function getRawAppPrompt(language?: string): string; export declare function getPipelinePrompt(): string; export declare function getDatatablePrompt(): string;