* feat: add wmill user list and document post-suspend approvers context Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: approvers holds the resume url approver string when set Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: regenerate cli skills for approvers wording Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to acb254ac509aff244849c09e50a8d39aae185138 This commit updates the EE repository reference after PR #852 was merged in windmill-ee-private. Previous ee-repo-ref: 9cc330f1a8e80bc075a6ad9839966d3b344f8ddd New ee-repo-ref: acb254ac509aff244849c09e50a8d39aae185138 Automated by sync-ee-ref workflow. * fix: drop name column from wmill user list, usr has no name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: approvalPage resumes record the logged-in user as approver Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
26 KiB
Windmill Flow Building Guide
OpenFlow Schema
The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.
Reserved Module IDs
failure- Reserved for failure handler modulepreprocessor- Reserved for preprocessor moduleInput- Reserved for flow input reference
Hard Structural Rules
These are strict Windmill schema rules. Follow them exactly.
value.modulesis only for normal sequential stepsvalue.preprocessor_moduleandvalue.failure_moduleare special top-level fields insidevalue, not entries invalue.modules- If a flow needs a preprocessor, create
value.preprocessor_modulewithid: preprocessor - If a flow needs a failure handler, create
value.failure_modulewithid: failure - Do NOT create regular modules inside
value.modulesnamedpreprocessororfailure preprocessor_moduleandfailure_moduleonly supportscriptorrawscriptpreprocessor_moduleruns before normal modules and cannot referenceresults.*failure_modulecan use theerrorobject witherror.message,error.step_id,error.name, anderror.stack- A flow whose
failure_moduleruns still ends as failed, unless the handler returns an object withrecover: true: the flow then ends as a success. This also holds when the failing step is inside a loop, a branch or a subflow.recovernever 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)
Correct shape:
value:
preprocessor_module:
id: preprocessor
value:
type: rawscript
...
failure_module:
id: failure
value:
type: rawscript
...
modules:
- id: process_event
value:
type: rawscript
...
Incorrect shape:
value:
modules:
- id: preprocessor
...
- id: process_event
...
- id: failure
...
Module ID Rules
- Must be unique across the entire flow
- Use underscores, not spaces (e.g.,
fetch_datanotfetch data) - Use descriptive names that reflect the step's purpose
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.
{
"id": "support_agent",
"summary": "AI agent for customer support",
"value": {
"type": "aiagent",
"input_transforms": {
"provider": {
"type": "static",
"value": { "kind": "openai", "resource": "$res:f/ai_providers/openai", "model": "gpt-4o" }
},
"output_type": { "type": "static", "value": "text" },
"user_message": { "type": "javascript", "expr": "flow_input.query" },
"system_prompt": { "type": "static", "value": "You are a helpful assistant." }
},
"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": "rawscript",
"language": "bun",
"content": "export async function main(query: string) { return ['doc1', 'doc2']; }",
"input_transforms": { "query": { "type": "static", "value": "" } }
}
}
]
}
}
provideris 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 throughvalue.agent. Static is right for a flow run from a form; a chat flow wires its fields to flow inputs instead — see below
Chat-Mode Flows
A flow with value.chat_input_enabled: true is run from a chat instead of a form: the composer
sends one message per turn and renders the conversation. It needs a required user_message string
input, read by the agent. Any other flow input the composer does not edit itself is asked for
under Configure inputs.
A static provider gives a chat that cannot change its model. Feed it from flow inputs
instead, either way round: one input carrying the whole object ("expr": "flow_input.model_config")
makes every field editable, or wire it field by field to fix some and expose others. A field the
chat can write becomes a control in the composer — a provider picker, a model list, a thinking
control — and a field left static is fixed, with no control drawn for it. kind is the one
exception: the composer writes it only together with resource, since a provider is picked as a
pair, so a kind input wired on its own stays askable under Configure inputs and nothing the run
needs becomes unreachable.
{
"id": "chat_agent",
"value": {
"type": "aiagent",
"input_transforms": {
"provider": {
"type": "javascript",
"expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })"
},
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
"memory": { "type": "static", "value": { "kind": "compaction" } },
"streaming": { "type": "static", "value": true },
"output_type": { "type": "static", "value": "text" }
},
"tools": []
}
}
- Wiring field by field means one object literal whose values are literals or bare
flow_input.xreferences. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bareflow_input.xfor the whole object is read instead as that one input carrying every field memoryis what lets the agent see earlier turns; without it every message starts from nothingstreamingon makes the answer and its thinking appear token by token instead of all at onceuser_attachmentspoints at a flow input typed as an array of s3 objects ({ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }), so files sent with a message reach the agent- Running one needs a
memory_idquery parameter — not a flow argument — naming the conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat supplies it itself; a run driven any other way has to pass it or the server refuses the job
Tool Naming Rules
These rules cover flowmodule tools, the ones the agent calls by name. A websearch tool's
summary is a plain label (Web Search), and an mcp tool exposes the MCP server's own tool
names, so neither is name-checked at all — leave those summaries as they are.
- A flowmodule tool's
summaryis the name the agent calls it by, not a human label. Put the human-readable explanation indescription summarymust match^[a-zA-Z0-9_]+$: letters, numbers and underscores only. No spaces, dashes, dots or accents —search_documentation, neverSearch documentation- Always set
summary. It must be unique among that agent's tools, and must not be one of the reserved ids (do,bg,ctx,state,if,else,for,delete,while,new,in,failure,preprocessor,as,Input,Result,Trigger) - A tool name outside that character set fails any run that offers the tool to the agent, with
Invalid tool name.
The flow write tools refuse such a name.
wmill lint <flow folder> reports it before anything runs.
- Tool
idfollows the same rules as any module ID — unique across the flow, underscores not spaces descriptionis optional free text telling the agent when and how to call the tool. Set it whenever the name alone does not make that obvious; it overrides the description derived from the underlying script
AI Decision Modules
An aidecision module asks a decision model (TypeSafe's Jev, Cloudflare's Clef or OpenAI's Decisions API) typed questions about a state and
answers each with calibrated probabilities instead of text. Prefer it over an aiagent when the step
is a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it
is faster, cheaper, and its answers have a fixed shape.
{
"id": "triage",
"summary": "Classify the ticket",
"value": {
"type": "aidecision",
"input_transforms": {
"provider": {
"type": "static",
"value": { "kind": "typesafe", "resource": "$res:f/ai/typesafe", "model": "jev-latest" }
},
"state": { "type": "javascript", "expr": "flow_input.message" },
"questions": {
"type": "static",
"value": {
"intent": {
"type": "choice",
"instructions": "What does the customer want?",
"criteria": { "refund": "Money back", "bug": "Something is broken", "other": "Anything else" }
},
"urgency": {
"type": "score",
"instructions": "How urgent is the ticket?",
"criteria": ["Can wait", "This week", "Today", "Right now"]
},
"angry": { "type": "noul", "instructions": "Is the customer angry?" }
}
}
}
}
}
provider.kindistypesafe(modeljev-latestunless a version is pinned),cloudflare(modelclef, orclef-flashfor faster answers) oropenai(modelgpt-6-luna, never a chat model); the resource is of that same typestateis the content to evaluate: usually a text, such as the message to classify. To combine several values, pass an object with descriptive keys holding only what the questions need (({ message: flow_input.message, plan: results.get_account.plan })); an array of strings also worksquestionsmaps each question name to{ type, instructions, criteria }:choice:criteriamaps each option to its description (up to 255 options). The answer haschoice,probabilitiesandconfidencescore:criteriais an ordered array of 2 to 10 level descriptions. The answer hasscore,legend,probabilitiesandconfidencenoul: yes/no,criteriaoptionally{ "true": ..., "false": ... }descriptions. The answer hasnoul, the probability of yes from 0 to 1
- The result is
{ output, model, usage }withoutputkeyed by question name, so a later step readsresults.triage.output.intent.choice,results.triage.output.urgency.scoreorresults.triage.output.angry.noul > 0.7 - On
openaithe samequestionsare translated for its Decisions API and the answers read the same way (choice,score,noul), except thatprobabilitiesis a list of{ value, probability }(pluslabelon a score) and a score has nolegend. A question OpenAI refuses to answer fails the step. Astatethat is an array of user messages ([{ role: "user", content: [{ type: "input_text", text }, { type: "input_image", image_url: "data:image/png;base64,..." }] }]) is sent as is, which is how an image is evaluated; any other object or array is sent as JSON text
Branching on the Answers
To route on the answers, put a branchone right after the decision, one branch per option, with
each expr reading the decision by id:
{
"id": "route",
"value": {
"type": "branchone",
"branches": [
{ "summary": "refund", "expr": "results.triage.output.intent.choice === 'refund'", "modules": [...] },
{ "summary": "bug", "expr": "results.triage.output.intent.choice === 'bug'", "modules": [...] }
],
"default": [...]
}
}
For a yes/no question, one branch with "expr": "results.triage.output.angry.noul >= 0.5" (tune the
threshold as needed) and the default as the "no" path.
As an Agent Tool
An aidecision can be a flowmodule tool of an aiagent ("tool_type": "flowmodule", "type": "aidecision"): set state to { "type": "ai" } so the calling model supplies it, and only output
goes back to the model. The Tool Naming Rules apply to its summary. Only a flow's own agent step
can call one, not an agent used as a tool.
Common Mistakes to Avoid
- Missing
input_transforms- Rawscript parameters won't receive values without them - Referencing future steps -
results.step_idonly works for steps that execute before the current one - Duplicate module IDs - Each module ID must be unique in the flow
- AI agent flowmodule tool names with spaces -
summaryis the tool name and only accepts letters, numbers and underscores
Data Flow Between Steps
flow_input.property- Access flow input parametersresults.step_id- Access output from a previous step only when that step result is in scoperesults.step_id.property- Access specific property from a previous step output only when that step result is in scopeflow_input.iter.value- Current iteration value inside aforloopflow; in awhileloopflowit is just the iteration index (a plain number, same asflow_input.iter.index)flow_input.iter.index- Current loop index when inside a loop (forloopfloworwhileloopflow)- After a suspend step (approval), the next step's javascript expressions also get:
resume- the last resume payload (the resume form values); withcontinue_on_disapprove_timeout, a disapproval or timeout yields{ error: { name: "SuspendedDisapproved" | "SuspendedTimedOut", message } }insteadresumes- every resume payload, in arrival orderapprovers- a plain array of strings, one per resume: for a resume through theresume/cancelURL fromgetResumeUrls(approver), theapproverpassed there if any; otherwise (including theapprovalPagelink) the resuming user's username, else"anonymous", e.g.approvers[0]. It is NOT the{ resume_id, approver }objects stored in the flow status, soapprovers[0].approveris always undefined
Loop Structure Rules
- A
forloopflowruns itsmodulesonce per element ofiterator, a javascript expression returning an array (e.g.results.get_items);parallel: trueruns the iterations concurrently, andskip_failures: truecarries on past a failed iteration - For
whileloopflow, break the loop with a module-levelstop_after_if: on the loop module itself, or on an inner step (required when that step carries state via its ownresults— see below) stop_after_ifis always a sibling ofidandvalueon a flow module — never a direct key of the loop'svalueobjectstop_after_all_iters_ifis for checks after the whole loop finishes, not the normal per-iteration break conditionstop_after_ifis evaluated after each iteration: on the loop module,resultis that iteration's result (what its last step returned); on an inner step, it is that step's resultflow_input.iter.valuein awhileloopflowis just the iteration index (same number asflow_input.iter.index) — it never carries state, soflow_input.iter.value.<field>is always undefined and a loop whose stop condition depends on it never terminates- To carry state across iterations, a step reads its own previous-iteration result via
results.<its_own_id>with a first-iteration fallback (e.g.results.b ?? flow_input.start) — but then the loop'sstop_after_ifMUST 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 whereresults.<step_id>is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its ownstop_after_if, retry or similar, resolveresultsacross iterations regardless of stop placement) - 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 withstop_after_ifon the loop module - 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
Correct whileloopflow shape:
- id: loop_until_done
stop_after_if:
expr: result.done === true
skip_if_stopped: false
value:
type: whileloopflow
skip_failures: false
modules:
- id: advance_state
value:
type: rawscript
input_transforms:
count:
type: javascript
expr: flow_input.iter.index + 1
- id: return_final_state
value:
type: rawscript
input_transforms:
final_state:
type: javascript
expr: results.loop_until_done[results.loop_until_done.length - 1]
Correct whileloopflow shape carrying state via results (stop condition on the inner step):
- id: loop_until_done
value:
type: whileloopflow
skip_failures: false
modules:
- id: advance_state
stop_after_if:
expr: result.done === true
skip_if_stopped: false
value:
type: rawscript
input_transforms:
state:
type: javascript
expr: results.advance_state ?? flow_input.initial_state
Incorrect whileloopflow patterns:
- id: loop_until_done
value:
type: whileloopflow
stop_after_if:
expr: result.done === true
input_transforms:
state:
type: javascript
# iter.value is a number (the iteration index); there is no previous-iteration state
expr: flow_input.iter.value.count
input_transforms:
final_state:
type: javascript
expr: results.loop_until_done
Approval / Suspend Structure
An 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.
suspendbelongs on the flow module object itself, as a sibling ofidandvalue- Never put
suspendinsidevalue - Do NOT use
type: identityfor 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.
Correct shape:
- id: request_approval
suspend:
required_events: 1
resume_form:
schema:
type: object
properties:
comment:
type: string
required: [comment]
value:
type: rawscript
language: bun
input_transforms:
approver:
type: static
value: ''
content: |
import * as wmill from "windmill-client"
export async function main(approver?: string) {
const urls = await wmill.getResumeUrls(approver)
// send urls.resume / urls.cancel to the approver(s), e.g. via Slack or email
return urls
}
Incorrect shape (suspend misplaced inside value):
- id: request_approval
value:
type: rawscript
suspend:
required_events: 1
Incorrect shape (identity has no resume URLs — not a real approval):
- id: request_approval
suspend:
required_events: 1
value:
type: identity
Branch Result Scope Rules
- A
branchoneruns the first of itsbrancheswhoseexpris true, in order, and itsdefaultmodules when none is; abranchallruns every branch (concurrently withparallel: true) - Inside a branch, you may reference earlier outer steps and earlier steps in the same branch
- Outside a
branchone, do NOT reference ids of steps that only exist inside its branches or default branch. Useresults.<branchone_module_id>instead - Outside a
branchall, do NOT reference ids of steps inside its branches. Useresults.<branchall_module_id>instead - If downstream steps need a stable shape after a branch, make each branch return the same fields
- When needed, add a normalization step immediately after the branch and consume
results.<branch_module_id>there
Correct after branchone:
- id: route_order
value:
type: branchone
...
- id: send_confirmation
value:
input_transforms:
routed:
type: javascript
expr: results.route_order
Incorrect after branchone:
expr: results.create_shipment
expr: results.create_backorder
Correct after branchall:
- id: enrich_parallel
value:
type: branchall
parallel: true
...
- id: combine_data
value:
input_transforms:
enrichments:
type: javascript
expr: results.enrich_parallel
Input Transforms
Every rawscript module needs input_transforms to map function parameters to values:
Static transform (fixed value): {"param_name": {"type": "static", "value": "fixed_string"}}
JavaScript transform (dynamic expression): {"param_name": {"type": "javascript", "expr": "results.previous_step.data"}}
Resource References
- For flow inputs: Use type
"object"with format"resource-{type}"(e.g.,"resource-postgresql") - For step inputs: Use static value
"$res:path/to/resource"
Reusing Existing Scripts and Flows
Unless 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:
- a workspace script:
type: scriptwithpath(e.g.f/folder/send_email) - a workspace flow, run as a subflow:
type: flowwithpath - a Hub script:
type: scriptwith ahub/<version>/<app>/<name>path
The step's input_transforms must cover the reused item's inputs, so read its input schema first.
Find candidates in the local tree (a .script.yaml sits next to each script and holds its input schema, a flow.yaml in each flow folder) and on the workspace with wmill script list / wmill flow list; wmill script get <path> and wmill flow get <path> show an item's details.
Additional Prompt for AI
A 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.
Organizing Flows: Groups and Notes
Groups and notes shape how a flow reads in the editor; neither changes what it does.
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.
value.groups lists the groups, each spanning the steps from start_id to end_id:
start_id,end_id(required): ids of the group's first and last step; the same id for both makes a one-step groupsummary: the group's titlenote: markdown shown under the titlecolor: one ofyellow,blue,green,purple,pink,orange,red,cyan,lime,gray, never a hex code or CSS color; leave it out and the editor picks oneautocollapse:trueshows the group collapsed by default
The editor refuses to draw a flow whose groups break any of these rules:
start_idandend_idare 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 itstart_iddoes not come afterend_idin that list- groups nest (one entirely inside another) but never partly overlap, and no two groups share both
start_idandend_id - groups hold ordinary steps only: never
preprocessor,failure,Input,Result,Trigger, or an AI agent's tools
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.
Give each note a position ({ x, y }) and a size ({ width, height }): the editor draws a note without them at the origin and cannot resize it. x: -400 with width: 275 places it beside the graph.
Leave a note's position and size out and they are filled in for you.
Final Structural Self-Check
Before finalizing a flow, verify:
- any preprocessor is in
value.preprocessor_module - any failure handler is in
value.failure_module - any approval step has module-level
suspend - no downstream step references inner branch step ids from outside the branch
- every AI agent flowmodule tool has a unique
summarymade only of letters, numbers and underscores - every group starts and ends on steps of the same list, start before end, nesting without partial overlap
wmill lint <flow folder>reports no error
S3 Object Operations
Windmill provides built-in support for S3-compatible storage operations.
To accept an S3 object as flow input:
{
"type": "object",
"properties": {
"file": {
"type": "object",
"format": "resource-s3_object",
"description": "File to process"
}
}
}
Using Resources in Flows
On Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.
As Flow Input
In the flow schema, set the property type to "object" with format "resource-{type}":
{
"type": "object",
"properties": {
"database": {
"type": "object",
"format": "resource-postgresql",
"description": "Database connection"
}
}
}
As Step Input (Static Reference)
Reference a specific resource using $res: prefix:
{
"database": {
"type": "static",
"value": "$res:f/folder/my_database"
}
}