* feat(sessions): hand a deployed run to the item's own page A deployed `run_script` / `run_flow` opened a form in the chat and started the job itself. It now opens the item's deployed page in the preview panel, fills the run form that is already there with the arguments it proposed, and the call ends. The run is the reader's: they start it from the page they would have run it from anyway. **Why the tool stops owning the run.** What the tool could carry was arguments and nothing else, so the page had to hide the controls it could not forward — scheduling, tag override, invisible-to-owner — and "Schedule to run later" ran immediately. It also ran HEAD whatever version the page was pinned to. Handing the page over costs the model nothing it had: it never ran anything, it waited for a reader to press Run. Now it says so and moves on; `get_run` is there when it needs the outcome. A TEST run is unchanged. It executes the draft being written, which the deployed page does not show, so it keeps the chat's own form. So does the bypass posture, which answers for a reader who has already consented and would leave nobody to press Run. Plan mode is unaffected: its gate already sits before the form, so it refuses before anything opens. **On the page.** The arguments arrive narrowed against the deployed schema by the tool that fetched it, and a note above the form says where they came from; dismissing it drops the note and keeps the values. A chat flow has no run form — its page is a conversation — so the proposed `user_message` goes into the composer instead, for the reader to edit and send. The composer declines if they were already typing. **On the card.** The call settles as `Opened`, with the arguments it filled under Inputs and no outcome pane: nothing ran, and a Result tab could only say the run came back empty. The eye reopens the page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): address review round 1 on the deployed-run handover - the chat-flow seed was latched before the composer existed, so a page opened fresh dropped the proposed message; the host's input is now reactive and the latch waits for an offer the composer actually took - that offer carries the run's other arguments too: a chat flow's schema can declare more than user_message, and the message alone would have run on saved values or schema defaults - the page takes its seeded arguments out of the runtime rather than reading them on every mount, so a later open of the same item is not re-seeded with an old turn's proposal - the card said "Done" beside "Opened", asserting a completion nothing claims - run_script / run_flow say what the call does in a session, and background / wait_seconds say they do not apply there - restored runFormCancelled's doc comment, displaced by the new helper Tests pin the three invariants the handover rests on: no job started, the model told the run has NOT happened, and neither a test run nor the bypass posture taking the branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): tell a mounting composer apart from a busy one The retry that waits for a chat flow's composer could not tell "not there yet" from "the reader is writing": both read as a declined offer, so the proposal stayed pending and would have landed in the box whenever the composer next changed — words appearing long after they were proposed. `composerReady` answers the first question on its own; past it the proposal is settled either way, and a reader's draft is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): address review round 2 on the deployed-run handover - a seed consumed into the viewer's own state carried no item key, so a tab re-pointed to another item got the first one's arguments pushed into its form, replacing its defaults, under a note crediting the agent for them - the handover ran after a stopped turn: the tool reads the item's schema first, and a stop during that read settles the card, which the handover then overwrote with a form so the card read `Opened` and hid the stop. The manager owns the turn, so the check lives there and declining hands the call to requestRunArgs, whose own stop guard settles it - the card kept the deployed schema in the persisted transcript although it never mounts a form - a chat flow's non-message inputs now land whether or not the message does, and the result string no longer asserts a fill the chat branch may not have made - the callback's `false` is documented by what actually causes it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): mint a chat flow's secret arguments before its run A chat flow's inputs reach the job straight from the composer, with no RunForm between them to turn a password-marked value into a reference as it is typed. The handover put the proposed arguments there directly, so a literal secret was stored in the job's arguments, readable by anyone who can read the run — the form path had always minted it first. runFlowForChat now does, which covers the reader's own typed inputs as well as a proposal's. Also from round 2: - a viewer pinned to an older version kept that pin when the handover merely focused it, so Run executed a version the arguments were not prepared against - the proposal now decides every input the schema declares, including the ones it omits: merging let a value saved on an earlier visit ride along in a run the chat announced without it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): apply a run proposal once, not on every remount The detail page remounts under its viewer whenever the item is deployed, refreshed or pinned to a version, and came back with its own latch reset — so the agent's proposal was pushed in again, over whatever the reader had typed since, bringing back a note they had dismissed. The proposal was made once. The viewer now holds it as pending until the page reports applying it, and says separately whether to credit the agent: the note stands until dismissed, the values land once. Also from the CI round's nits: - a pinned viewer the handover re-points is brought to the front, since the call is about to say the page is open - offerMessage's doc described inputs it no longer carries; applyInputs owns them, deliberately unconditional on the message landing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): show the panel on a pinned handover, keep the eye alive after a stop Both regressions from the previous round's fixes. Re-pointing a pinned viewer did neither of the things `open` does besides re-targeting: a collapsed panel stayed collapsed while the call told the reader the page was open. The stop check sat on the method the card's eye calls too. The controller is per-turn and stays aborted once stopped, so the eye went dead on every card in a chat where any turn had been stopped. The tools now go through a guarded variant; the reader's own way in carries no turn check, because reopening a page they closed has nothing to do with a turn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(sessions): put the run-form doc comments back on their functions Inserting the two handover methods stranded handleRunFormSubmit's comment above one of them, and the host hook's comment still named a tool path that no longer reaches it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(sessions): run a deployed item from its own page, for the chat The run card stays in the chat by default. Preview opens the item's deployed page in the preview panel with the proposed arguments already filled, and Run there hands them back to the parked tool call — so the chat still owns the job and reports on its result. A chat flow has no run form to fill: its page is a conversation, so the proposed message goes into the composer and a strip above it says what was asked for. Sending it runs the flow in that conversation, which settles the call with a reason rather than a refusal — the card says "Handed off", not "Cancelled". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): hand the call back when the page cannot honour it A page carries an adopted call only while it can run what the call asked for. Picking a version broke that silently: the tool starts its job by path, so Run on a page pinned to an older version ran the deployed one instead — the page said v1 and the job returned v3. The page now hands the call back when it is on a version, and the card takes its form back, which is where the call can still be confirmed as proposed. Whether the call is still waiting is the manager's answer, not the adoption map's. Reading it live covers the ends that reach no hook: a submit whose job never started, because the server refused it or plan mode blocked it after the arguments were handed over. Those left the page with a dead Run and no way out but the X. The page's Run also has to clear what the card's clears before the form mints a password into a workspace variable — plan mode, and a second press. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): bind a press on the page to the call it started against A call stops being pending the instant Run is pressed, well before its job exists. The page went back to running on its own in that gap, so a second press there started a job the chat never heard about. A press now holds the call until the job lands or the call settles. A press also kept reading its runner across `processSecretArgs`: cancelling while a password was being minted switched the press to the page's own runner, which then started a job the reader had just cancelled. The runner is read once, when the press begins. A chat flow's message waits on that same round trip. It needs nothing from it, but a composer filled ahead of the inputs can be sent ahead of them, and the run would carry the inputs the proposal was meant to replace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): tell the form when a submitted call will never get a job A surface that confirmed a run waits for the job it was promised, and a refused start is the one end it cannot see: the call is neither pending nor cancelled. It was inferred from the call no longer being pending, which stopped working as soon as a press had to outlive that — the page then waited forever, offering a Run that reached nothing. `markRunFormEnded` is the mirror of `markRunFormStarted`: the server refused the job, a sensitive argument could not be stored, or plan mode blocked it after the arguments were handed over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): bind the page's async work to the call it started with The page reads the call it is carrying from a derived, and every await on it outlives that read: a call can settle and the next request for the same item be adopted inside one `processSecretArgs` round trip. Read live on the far side, the page would submit one request's arguments to another, decline a proposal the reader had not been shown, or seed a composer over the one they were looking at. Each of these now holds the call it began with. The form's own press holds its guard too, so a replacement cannot have its latch released by the press before it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(sessions): let the tab carry the call, and leave chat flows alone The page that answers a parked run form was found by item, so anything on that page could resolve the call and act on it: a version picked after the fact, a press that outlived its call, a send in some other conversation. Each was fixed where it surfaced, and the next one surfaced somewhere else. The tab carries the call now, named in its url beside the version pin. Only the tab opened for a call can answer it, and re-pointing that tab anywhere drops the name, so a version picked on the page hands the call back with nothing to check for it. The item-keyed map and the lifecycle hooks around it are gone. A chat-enabled flow's page is a conversation with no run form, so it cannot answer a call at all: Preview opens it to look at and the card keeps the run, which is what the agent needs to report a result. That deletes the composer seeding, the strip above it, and the "Handed off" outcome that existed only to describe a run the agent never saw. Prefilling that composer is worth having on its own and comes back in its own PR. Running a deployed chat-enabled flow needs a memory id, which this path never sent: the card's Run answered with a 400. Each such run now starts its own conversation on the flow's page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): keep the page's promises and its edits when it holds a call The dynamic-select helper runs its job when the field mounts, not when Run is pressed, so a form relocated to the page ran one under plan mode — which the card withholds for exactly that reason. The page owes the same promise while it is standing in for a card. Its form is a copy, so arguments typed there were dropped when the call went back to the card: it then offered, and ran, what the page had opened with. The call now carries them back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): take no writes on a relocated form under plan mode A password field mints an ephemeral workspace variable as it is typed, so withholding the dynamic-select helper was half the promise: the field itself has to be unwritable. One flag now covers both, as the card's own form already did. The hand-back is keyed on the call rather than on the object naming it, which is rebuilt whenever the draft or the posture changes — its own write to that draft is what would have re-run it. It also hands back only what its own form held: the next call for this item re-points the same tab without remounting, and its seeding can land first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): close the JSON route into a read-only run form Disabling the fields left the other way in: arguments written as JSON reach the same object, and a password set there is a plain literal until the schema field mounts onto it — which mints it whether or not that field is disabled. The editor's writes are refused while the form is read-only, and the toggle offering it is not drawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(sessions): hand the form back on destroy rather than from an effect Every way the page stops carrying a call and still holds edits destroys the component: the tab closes, or its url changes and `loadKey` remounts it. So the hand-back is a destroy hook, not a reaction to the call changing — which needed a key and two `untrack`s to keep its own write from re-running it. The call is the one this form was seeded for, captured where it is seeded. By the time the hook runs the tab has already stopped naming it, so reading it then would find nothing to hand back to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(sessions): seed the run form as it is built, not after it mounts The form takes what it opens holding as a prop, and the page renders it under the call's id. A request arriving on a tab that is already open builds a new form already filled, so there is no mounted instance to reach for and nothing to wait on — which is all the seeding effect was doing. That also settles where the hand-back reads its call: with no instance to track, `onDestroy` reads the prop, which is still there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): hand back only what a form actually held A tab closed while the deployed item was still being fetched handed the card an empty object, wiping the proposal it was still parked on: the page registers its destroy hook long before `RunForm` gives `args` a value. The comment above it also claimed every way out destroys the page. The next call re-pointing the same tab does not, and that page's edits stop there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): hand back the PowerShell common parameters too `RunForm` keeps `_wm_ps_*` out of `args` and puts them back only as it submits, so handing `args` to the card dropped whatever the reader had chosen on the page — ErrorAction among them, which falls back to Stop. The form now answers with what a run would send. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(sessions): let the page edit the call's own draft The page kept a copy of the arguments and handed it back when it went away, so everything `RunForm` does to arguments on the way through had to be mirrored on the way out: the PowerShell split was the third thing found missing from that mirror. The form now binds the draft the card binds, so there is one set of arguments and nothing to carry anywhere. That removes the hand-back, the destroy hook holding it, the `initialArgs` the form was seeded through and the guard that stopped an unseeded form wiping the proposal. A call arriving on an open tab still rebuilds the form, which is what `{#key}` is for; it now opens on the draft rather than on a prop. The PowerShell section follows the rule the rest of the run settings already follow: a call carries arguments and nothing else, so the section is not offered while the form stands in for a card — nothing can be chosen there and lost. Its keys stay in the arguments when the section is not offered, so a proposal carrying them reaches the job either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sessions): keep the submitted arguments on screen after Run Pressing Run settles the call, so the draft the form reads stops answering before the job exists: the form emptied for as long as the start took, and stayed empty when the server refused it — leaving the reader the run to make again with nothing to make it from. It falls back to what it submitted. The note on the PowerShell split also claimed the unoffered keys travel to the job. `SchemaForm` holds the arguments to the keys the schema declares, so they do not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(sessions): trim the comments on the adopted-run path Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(sessions): let the page run it and hand the call its job The page kept the chat's arguments in step and ran on the chat's behalf, which meant reproducing everything `RunForm` does to arguments on the way through — the PowerShell split was the third thing found missing from that. It now runs the way it runs anything, with its own form, its own version and its own arguments, and tells the call which job that was. The tool follows that job instead of starting one. The proposal reaches the form through the url hash the deployed pages already read for "Run again", so nothing seeds it. `PendingRun` is three members, and `RunForm` goes back untouched but for the plan-mode gate. Two classes go with the copy. A version pinned on the page is simply the version that runs, since the page starts the job. PowerShell common parameters travel with it for the same reason. Plan mode still has to reach the page: a `password` mints as it is typed and the dynamic-select helper runs on mount, both before Run is pressed, and the page's own Run would otherwise start a job the chat promised not to make. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: carry a run proposal on the call, not in the tab url The proposal reached the deployed page through the tab url's hash, which the page already reads for "Run again". Two things were wrong with that carrier: - A tab already open on the item does not remount when it is re-pointed, and both detail pages read the hash once at init — so a second proposal for the same item opened its form on the first one's arguments. - Tab urls are persisted with the session, so a draft holding file bytes or an un-minted `password` literal was written to IndexedDB. The transcript avoids both deliberately, through `redactFileArgs` and `MAX_PERSISTED_ARGS_LENGTH`. The call now carries its own proposal: `PendingRun.proposedArgs` reads the card's live draft, the page seeds its form from it at init, and the tab's url names only the call. `ItemViewerView`'s load key includes that name, so a tab re-pointed to another call builds its form again. Two more fixes on the same path: - `adopt` was read off the prop after awaiting job creation, so a call that settled during the request could be answered with another call's job. The press now captures the call it was made under. - An adopted job's arguments and version are the page's, which this tool cannot read, but the model was told `Ran with arguments: {}`. It is now told the user ran it on the item's own page, and that the arguments are the page's. - Adopting hands the carrying tab to the run, so the page no longer navigates there as well — that left two tabs open on the same job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: refuse a run in the form, and show an adopted job's own arguments Two findings on the deployed page carrying a chat call. `RunForm.run()` mints a variable per `password` argument before it calls `runAction`, so the plan-mode gate in the detail pages ran after the write it was there to prevent — and the keybinding reached it without the button. The refusal moves into the form, as `runRefusal`: set, it takes no writes at all, its submit buttons are disabled, and `run()` says so and returns before the secret processing. The page also stops offering the JSON editor under plan mode, instead of leaving one mounted that silently discards what is typed. An adopted job's arguments are the page's, and the card had no way to show them: it kept rendering the proposal, so a reader who edited a field saw the wrong values and the model was told nothing. `adopt` now hands them over with the job id, and they go through the same redaction and size guard as a run this tool starts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the refused form on the side it is showing Closing the JSON editor when the form starts refusing writes mounted `SchemaForm` in its place, and `PasswordArgInput` mints a variable from the literal it opens on without consulting `disabled` — so a reader who typed a plaintext secret into JSON view had it stored by the act of entering plan mode, with no run and no Run press. The form no longer swaps sides while it refuses: `JsonInputs` takes `readOnly` and the editor stays mounted and uneditable. The JSON toggle is already hidden in that state, which is what keeps the fields from mounting from the other direction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact an adopted run against the schema its page ran Redaction only visits the leaves the schema it is given declares, and the page runs the deployed version as it stands when Run is pressed. A deploy between this call reading the schema and that press can add a file argument ours never had, and its bytes then passed unredacted into the persisted card and the model's result, under the size limit that would have caught a large one. `adopt` hands over the schema it ran against alongside the job and arguments, and both redactions use it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: sit the parked-call banner in the form's own header row The banner was its own block directly above the form, so it sat flush against the first field. It now shares the row that carries the JSON toggle, where the existing input badges get their spacing from `min-h-12`. It goes ahead of the saved-input badge, whose reserved slot stays mounted while hidden and would otherwise push the banner off the left edge. The row's gate takes `pendingRun` as well, since a runnable with no schema properties renders no row and would drop the banner with it. The badge's reject button also takes the alert palette it sits on, through `tone`, instead of the neutral bordered one it had from the deprecated `color`/`size` props. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: hand a proposed run to the page already showing it A reader who declines a proposal and asks for different arguments still has that item's page open, and the next proposal belongs on it — but it was parked on the card until Preview was pressed again. `requestRunArgs` now routes the call to that page itself, through `openRunOnDeployedPage` with `onlyIfAlreadyOpen`: a tab already on the item is re-pointed, and anything else — a tab showing a run included — answers no and leaves the form on the card. The card keeps a working form while a page carries the call, rather than reporting that its inputs are elsewhere. Only the chat's own form in a tab is exclusive with it, since that one is the same draft and two mounts each reorder the schema in place; a page builds its form from a copy of the proposal and shares nothing. So the card folds its row instead of emptying it, and expanding it is how a run goes from the transcript. `isRunFormTabOpen` answers that narrower question, and the collapse moves into the row's default so pressing Preview and being routed here behave alike. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: let one call start only one job across its two surfaces The card's form and the deployed page's are now live at the same time, and only the card took the manager's submission claim. The call stays pending until `adopt`, a round trip after the page sends its request, so a Run on the card in that window passed the claim and started a second job — with its own arguments, and unmentioned in the chat, which followed the card's. The page takes the same claim before it sends, and hands it back when no job came of it so a failed start can be retried. A successful start needs no release: adopting the job settles the call. Whichever surface is first owns the call; the other sends nothing and says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: leave a busy tab alone when handing it a proposal The re-point branch was written for Preview, which the reader presses on purpose, and now also runs for every proposal of an item whose page is open. Unasked for, two of the things it does are not wanted: `navigate` rebuilds the tab's url without the version the reader pinned, and `select` with `setCollapsed` pulls the panel off whatever tab they were reading. So the automatic path declines a pinned tab outright, leaving the pin and the call on the card, and moves the panel only when that tab is the one already on screen. An explicit Preview press keeps the behaviour it had. A hand-off still remounts the page, so text typed into its form is lost. That needs the page to report unsent edits upward, and is left out. Tests the routing gate: a deployed run is handed over, a test run and a chat-enabled flow are not — neither has a page with a form that could answer the call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: claim a run before the form mints its secrets, not after The claim sat in `runAction`, which `RunForm.run()` reaches only after `processSecretArgs` has written a variable per `password` argument. So a press that lost the race still minted — and worse, it read the pending call after that round trip, by which point the winner had settled it: with no call to claim, the losing press started a job of its own. `runRefusal` already documents this boundary, saying a caller cannot gate in `runAction` because it comes after the mint. The claim belongs on the same side, so the form takes it through `claimRun` just before the first write and hands it back through `releaseRun` when the mint fails. The pages keep the call they claimed rather than re-reading it, so a claimed press either answers that call or does nothing. The chat card's own form already worked this way, taking the claim ahead of its `processSecretArgs`; the page was the asymmetric surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hand a proposal only to the page the reader is looking at Re-pointing a tab reloads it and rebuilds its url. On a tab that is not on screen that silently drops whatever had been typed into its form and leaves the call on a page the reader cannot see, while the card folds because the call is carried — so the run is confirmable on neither surface in view. Declining instead leaves it on the card, which can run it. The automatic path therefore requires the matching tab to be the active one in an open panel, alongside the existing version-pin check, and the three ways of being busy read as one condition. An explicit Preview press still re-points, reveals and unpins, which is what pressing it asks for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "fix: claim a run before the form mints its secrets, not after" This reverts6c2ad4bbf6and the claim it built on in5ea8769efe, leaving the hand-off rule from93ac4f495ein place. Both surfaces of a parked call show a Run button, and pressing both starts two jobs. That is two deliberate confirmations of one proposal, and pressing Run twice on a deployed page has always started two jobs — nothing in Windmill deduplicates a run, because a run is not an idempotent operation. The window is one job-creation round trip. Covering it cost a prop on `RunForm`, which pages outside this feature use, to coordinate something that only arises in a session's preview panel. It also created a problem of its own: a losing press minted an ephemeral variable per `password` argument for a run it then refused, leaving the workspace with secrets belonging to no job. Without the claim there is no losing press, so every mint belongs to a job that ran. What remains true and intended: the second job is a normal run, listed and reported like any other, and the chat follows only the one that answered its call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Open-source developer platform for internal code: APIs, background jobs, workflows and UIs. Self-hostable alternative to Retool, Pipedream, Superblocks and a simplified Temporal with autogenerated UIs and custom UIs to trigger workflows and scripts as internal apps.
Scripts are turned into sharable UIs automatically, and can be composed together into flows or used into richer apps built with low-code. Supported languages: Python, TypeScript, Go, Bash, SQL, GraphQL, PowerShell, Rust, and more.
Try it - Website - Docs - Discord - Hub - Contributing
Windmill - Developer platform for APIs, background jobs, workflows and UIs
Windmill is fully open-sourced (AGPLv3) and Windmill Labs offers dedicated instances and commercial support and licenses.
https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4
- Windmill - Developer platform for APIs, background jobs, workflows and UIs
Main Concepts
- Define a minimal and generic script in Python, TypeScript, Go or Bash that solves a specific task. The code can be defined in the provided Web IDE or synchronized with your own GitHub repo (e.g. through VS Code extension): provided Web IDE or synchronized with your own GitHub repo (e.g. through VS Code extension):
- Your scripts parameters are automatically parsed and generate a frontend.
- Make it flow! You can chain your scripts or scripts made by the community shared on WindmillHub.
- Build complex UIs on top of your scripts and flows.
Scripts and flows can be triggered by schedules, webhooks, HTTP routes, Kafka, WebSockets, emails, and more.
Build your entire infra on top of Windmill!
Show me some actual script code
//import any dependency from npm
import * as wmill from "windmill-client";
import * as cowsay from "cowsay@1.5.0";
// fill the type, or use the +Resource type to get a type-safe reference to a resource
type Postgresql = {
host: string;
port: number;
user: string;
dbname: string;
sslmode: string;
password: string;
};
export async function main(
a: number,
b: "my" | "enum",
c: Postgresql,
d = "inferred type string from default arg",
e = { nested: "object" }
//f: wmill.Base64
) {
const email = process.env["WM_EMAIL"];
// variables are permissioned and by path
let variable = await wmill.getVariable("f/company-folder/my_secret");
const lastTimeRun = await wmill.getState();
// logs are printed and always inspectable
console.log(cowsay.say({ text: "hello " + email + " " + lastTimeRun }));
await wmill.setState(Date.now());
// return is serialized as JSON
return { foo: d, variable };
}
Local Development
Windmill supports multiple ways to develop locally and sync with your instance:
| Tool | Description |
|---|---|
| CLI | Sync scripts from local files or GitHub, run scripts/flows from the command line |
| VS Code Extension | Edit and test scripts & flows directly from VS Code / Cursor with full IDE support |
| Git Sync | Two-way sync between Windmill and your Git repository |
| Claude Code | AI-assisted development with Claude for scripts, flows, and apps |
https://github.com/user-attachments/assets/c541c326-e9ae-4602-a09a-1989aaded1e9
You can run scripts locally by passing the right environment variables for the wmill client library to fetch resources and variables from your instance. See local development docs.
Stack
- Database: Postgres (compatible with Aurora, Cloud SQL, Neon, Azure PostgreSQL)
- Backend: Rust - stateless API servers and workers pulling jobs from a Postgres queue
- Frontend: Svelte 5
- Sandboxing: nsjail and PID namespace isolation
- Runtimes:
- TypeScript/JavaScript: Bun (default); Deno only on the
windmill-fullimages - Python: python3 with uv for dependency management
- Go, Bash, PowerShell, PHP, Rust, C#, Java, Ansible
- TypeScript/JavaScript: Bun (default); Deno only on the
Fastest Self-Hostable Workflow Engine
We have compared Windmill to other self-hostable workflow engines (Airflow, Prefect & Temporal) and Windmill is the most performant solution for both benchmarks: one flow composed of 40 lightweight tasks & one flow composed of 10 long-running tasks.
All methodology & results on our Benchmarks page.
Security
- Sandboxing: nsjail for filesystem/resource isolation, and PID namespace isolation (enabled by default) to prevent jobs from accessing worker process memory
- Secrets: One encryption key per workspace for credentials stored in Windmill's K/V store. We recommend encrypting the Postgres database as well.
See Security documentation for details.
Performance
Once a job started, there is no overhead compared to running the same script on the node with its corresponding runner (Deno/Go/Python/Bash). The added latency from a job being pulled from the queue, started, and then having its result sent back to the database is ~50ms. A typical lightweight deno job will take around 100ms total.
Architecture
How to self-host
For detailed setup options, see Self-Host documentation.
Docker compose
Deploy Windmill with 3 files (docker-compose.yml, Caddyfile, .env):
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml -o docker-compose.yml
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/Caddyfile -o Caddyfile
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/.env -o .env
docker compose up -d
Go to http://localhost - default credentials: admin@windmill.dev / changeme
Using an external database: Set DATABASE_URL in .env to point to your managed Postgres (AWS RDS, GCP Cloud SQL, Azure, Neon, etc.) and set db replicas to 0.
Kubernetes (Helm charts)
helm repo add windmill https://windmill-labs.github.io/windmill-helm-charts/
helm install windmill-chart windmill/windmill --namespace=windmill --create-namespace
See windmill-helm-charts for configuration options.
Cloud providers
Windmill works on AWS (EKS/ECS), GCP, Azure, Ubicloud, Fly.io, Render.com, Hetzner, Digital Ocean, and others. Rule of thumb: 1 worker per 1vCPU and 1-2 GB RAM.
OAuth, SSO & SMTP
Configure OAuth and SSO (Google Workspace, Microsoft/Azure, Okta) directly from the superadmin UI. See documentation.
License
The Community Edition is free to use internally. For commercial redistribution or managed services, contact sales@windmill.dev. See LICENSE and Pricing for details.
The "Community Edition" of Windmill available in the docker images hosted under ghcr.io/windmill-labs/windmill and the github binary releases contains the files under the AGPLv3 and Apache 2 sources but also includes proprietary and non-public code and features which are not open source and under the following terms: Windmill Labs, Inc. grants a right to use all the features of the "Community Edition" for free without restrictions other than the limits and quotas set in the software and a right to distribute the community edition as is but not to sell, resell, serve Windmill as a managed service, modify or wrap under any form without an explicit agreement.
The binary compilable from source code in this repository without the "enterprise" feature flag is open-source under the LICENSE-AGPLv3 License terms and conditions.
To re-expose directly any Windmill parts to your users as a feature of your product, with the exception of iframed public Windmill "apps", or to build a feature on top of "Windmill Community Edition" that you sell commercially or embed in a distributable product or binary, you must get a commercial license. Contact us at sales@windmill.dev if you have any questions. To do the same from the binary compiled from the source code in this repository without the "enterprise" feature flag, you must comply with the AGPLv3 license terms and conditions or get a commercial license from Windmill Labs, Inc.
To use Windmill "Community Edition" as is internally in your organization, or to use its APIs as is, you do NOT need a commercial license.
Integrations
In Windmill, integrations are referred to as resources and resource types. Each Resource has a Resource Type that defines the schema that the resource needs to implement.
On self-hosted instances, you might want to import all the approved resource types from WindmillHub. A setup script will prompt you to have it being synced automatically everyday.
Environment Variables
| Environment Variable name | Default | Description | Api Server/Worker/All |
|---|---|---|---|
| DATABASE_URL | The Postgres database url. | All | |
| WORKER_GROUP | default | The worker group the worker belongs to and get its configuration pulled from | Worker |
| MODE | standalone | The mode if the binary. Possible values: standalone, worker, server, agent | All |
| METRICS_ADDR | None | (ee only) The socket addr at which to expose Prometheus metrics at the /metrics path. Set to "true" to expose it on port 8001 | All |
| JSON_FMT | false | Output the logs in json format instead of logfmt | All |
| BASE_URL | http://localhost:8000 | The base url that is exposed publicly to access your instance. Is overriden by the instance settings if any. | Server |
| ZOMBIE_JOB_TIMEOUT | 30 | The timeout after which a job is considered to be zombie if the worker did not send pings about processing the job (every server check for zombie jobs every 30s) | Server |
| RESTART_ZOMBIE_JOBS | true | If true then a zombie job is restarted (in-place with the same uuid and some logs), if false the zombie job is failed | Server |
| NATIVE_MODE | false | Enable native mode: sets NUM_WORKERS=8, rejects non-native jobs (nativets, postgresql, mysql, etc.) | Worker |
| SLEEP_QUEUE | 50 | The number of ms to sleep in between the last check for new jobs in the DB. It is multiplied by NUM_WORKERS such that in average, for one worker instance, there is one pull every SLEEP_QUEUE ms. | Worker |
| KEEP_JOB_DIR | false | Keep the job directory after the job is done. Useful for debugging. | Worker |
| EXIT_AFTER_N_JOBS | None | Exit the worker process after it has executed that many jobs, so that a supervisor restarts it and no process runs more than that many, bar the steps of a same-worker flow it has started, which it always finishes (set it to 1 for a process per job; jobs handed to a dedicated worker, and the worker's own init and periodic scripts, do not count). Not counting the init and periodic scripts means they run again on every restart: an init script's runtime is added to the latency of every batch of that many jobs, and a periodic script fires once per process start whatever its interval says. The worker's shell in the workers page also starts backed off rather than after the two minutes it otherwise takes, since a process due to be recycled cannot count on living that long: the first command of a session can wait up to 15s, later ones are immediate. For deployments that isolate executions by process lifetime rather than with nsjail; note that a container restart resets the process, not the container filesystem, so caches and /tmp survive it. The worker name is then derived from the hostname instead of being random, so the restarted worker keeps its row in the workers list (an agent worker keeps the row but restarts its job count). Use one worker per process: workers of one process share its environment, so the first to reach the limit shuts the others down too. |
Worker |
| WORKER_SUFFIX | None | Pins the last part of the worker name, which is otherwise random, so that a restarted worker keeps its row in the workers list. Only needed when several worker processes of the same worker group run on one host, since the name is derived from the hostname: give each of them a distinct value, as two processes sharing one must never happen. At most 64 letters, digits and underscores; anything else is refused at startup. | Worker |
| LICENSE_KEY (EE only) | None | License key checked at startup for the Enterprise Edition of Windmill | Worker |
| SLACK_SIGNING_SECRET | None | The signing secret of your Slack app. See Slack documentation | Server |
| COOKIE_DOMAIN | None | The domain of the cookie. If not set, the cookie will be set by the browser based on the full origin | Server |
| DENO_PATH | /usr/bin/deno | The path to the deno binary. | Worker |
| PYTHON_PATH | The path to the python binary if wanting to not have it managed by uv. | Worker | |
| GO_PATH | /usr/bin/go | The path to the go binary. | Worker |
| GOPRIVATE | The GOPRIVATE env variable to use private go modules | Worker | |
| GOPROXY | The GOPROXY env variable to use | Worker | |
| NETRC | The netrc content to use a private go registry | Worker | |
| PY_CONCURRENT_DOWNLOADS | 20 | Sets the maximum number of in-flight concurrent python downloads that windmill will perform at any given time. | Worker |
| PATH | None | The path environment variable, usually inherited | Worker |
| HOME | None | The home directory to use for Go and Bash , usually inherited | Worker |
| DATABASE_CONNECTIONS | 50 (Server)/3 (Worker) | The max number of connections in the database connection pool | All |
| SUPERADMIN_SECRET | None | A token that would let the caller act as a virtual superadmin superadmin@windmill.dev | Server |
| TIMEOUT_WAIT_RESULT | 20 | The number of seconds to wait before timeout on the 'run_wait_result' endpoint | Worker |
| QUEUE_LIMIT_WAIT_RESULT | None | The number of max jobs in the queue before rejecting immediately the request in 'run_wait_result' endpoint. Takes precedence on the query arg. If none is specified, there are no limit. | Worker |
| DENO_AUTH_TOKENS | None | Custom DENO_AUTH_TOKENS to pass to worker to allow the use of private modules | Worker |
| DISABLE_RESPONSE_LOGS | false | Disable response logs | Server |
| CREATE_WORKSPACE_REQUIRE_SUPERADMIN | true | If true, only superadmins can create new workspaces | Server |
| MIN_FREE_DISK_SPACE_MB | 15000 | Minimum amount of free space on worker. Sends critical alert if worker has less free space. | Worker |
| RUN_UPDATE_CA_CERTIFICATE_AT_START | false | If true, runs CA certificate update command at startup before other initialization | All |
| RUN_UPDATE_CA_CERTIFICATE_PATH | /usr/sbin/update-ca-certificates | Path to the CA certificate update command/script to run when RUN_UPDATE_CA_CERTIFICATE_AT_START is true | All |
| GOOGLE_APPLICATION_CREDENTIALS | None | (ee only) Credentials file for GCP Pub/Sub triggers that authenticate as the instance rather than through a gcloud resource (workspace admins only). Application default credentials also resolve the gcloud well-known file and the GCE metadata server. Workload Identity Federation files work with the file, url and aws credential sources; the executable source is not supported. |
Server |
Run a local dev setup
We recommend using Nix. See ./frontend/README_DEV.md for all options.
Frontend only
Uses the backend of https://app.windmill.dev with local frontend (hot-reload):
cd frontend
npm install
npm run generate-backend-client # or generate-backend-client-mac on Mac
npm run dev
Windmill available at http://localhost/
Backend + Frontend
See the ./frontend/README_DEV.md file for all running options.
- Start a local Postgres database using for instance the
start-dev-db.shscript which will make a database available atpostgres://postgres:changeme@localhost:5432/windmillThen run the migrations using the following command:This will also avoid compile time issue with sqlx'scargo install sqlx-cli env DATABASE_URL=<YOUR_DATABASE_URL> sqlx migrate runquery!macro. - (optional, linux only) Install nsjail and have it accessible in your PATH
- Install bun and python3 (+ any languages you want to use), have the bins at
/usr/bin/bunand/usr/local/bin/python3or set the corresponding environment variables. - (optional) Install the lld linker
- Go to
frontend/:npm install,npm run generate-backend-clientthenREMOTE=http://localhost:8000 npm run dev- You might need to set some extra heap space for the node runtime
export NODE_OPTIONS="--max-old-space-size=4096" - Create an empty
frontend/buildfolder usingmkdir frontend/build
- Go to
backend/:env DATABASE_URL=<YOUR_DATABASE_URL> RUST_LOG=info cargo run- You can specify any feature flag you want to enable, for example
cargo run --features pythonto enable the python executor.
- Windmill should be available at
http://localhost:3000
Contributing
At this time, we are not seeking outside contribution. Bug reports and feature requests remain very welcome, and small, trivially-verified PRs that fix a problem are still accepted. See CONTRIBUTING.md for the full policy.
Contributors
Copyright
© 2023-2026 Windmill Labs, Inc.






