* feat(cli): add `wmill flow dev` subcommand with per-flow reverse proxy and launch.json Also generates .claude/launch.json for existing flow folders during `wmill init`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: responsive dev layout and hide splitter for single-pane views Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: clamp flow graph height between minHeight and maxHeight Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(cli): enhance app new with Claude Desktop integration and better defaults - Add .claude/launch.json to generated app scaffold for Claude Code preview support - Add "Open in Claude Desktop?" prompt that creates a CLI session and opens it in Claude Desktop Code mode via the claude://resume deep link - Improve default CSS template with body background, system fonts, and padding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): handle both .flow and __flow suffixes in wmill dev The flow detection in loadPaths only checked the configured suffix (dotted or non-dotted), so users with nonDottedPaths=true who had .flow folders (or vice versa) would see inline script edits treated as standalone script changes instead of flow changes. Now checks both suffix forms everywhere: type classification, folder path extraction, path stripping, and loadWmPath lookup. Also adds raw_app launch.json generation to init and sync pull. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(cli): update generated skills with dev workflow and preview commands Update cli-commands, write-flow, and raw-app skills to document the new local dev workflow (wmill dev --path, --proxy-port, .claude/launch.json). Add wmill script preview and wmill flow preview to all script/flow skills so agents know how to test without deploying. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): include path in dev URL and use open.default for browser - Append &path= to the printed/opened URL when --path is specified - Use open.default(url) instead of open.openApp for more reliable browser opening Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(cli): add Claude CLI/Desktop detection hints in wmill flow new Show contextual instructions for previewing flows based on available tools. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate auto-generated CLI skills for new dev flags Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): handle mixed flow suffixes in dev file watcher The ignore() function uses isFlowPath() which only checks the configured suffix (__flow or .flow), causing files in the other variant to be silently ignored. Bypass the ignore check for any file inside a flow folder and force flow type detection regardless of suffix configuration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(cli): drop default proxy in flow folders, open browser, add --no-browser Manual `wmill dev` in a flow folder should not implicitly enable the reverse proxy. Both proxy and legacy modes now open the browser; the new --no-browser flag opts out. Claude Code launch.json templates pass --no-browser so the IDE preview doesn't fight a system browser window. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): gate dev broadcasts by --path and push currentLastEdit on connect When --path (or auto-detected flow path) is set, drop file events for any other path so the dev page stays locked to the requested resource and currentLastEdit can never reflect an unrelated edit. The connection handler proactively pushes currentLastEdit so the page renders without waiting for the first file change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): prefer WebSocket for flow round-trip when wmill dev is connected updateFlow used isInIframe priority, which routed Claude Code's iframe preview through postMessage (no listener) and silently dropped flow edits. Flip the priority: when the wmill dev WebSocket is open, use it (covers standalone tabs and Claude Code's preview); fall back to postMessage only when no WS is connected (the VS Code extension's iframe URL has no `local=true`, so it never opens one). Also stop assigning lastSent before a channel actually accepted the message, so a CONNECTING WS doesn't silently swallow the first change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dev): url is source of truth for path; add workspace file picker Drops the server-side --path gate added in3c2d5155e1. The dev page now filters by its URL's ?path= and the CLI is a dumb broadcaster, which lets multiple tabs each watch different paths. When the URL has no ?path=, the page asks the CLI for a list of workspace items (flows, scripts, raw_apps) via a new {type:'listPaths'} WS message and renders a picker. Clicking a flow or script soft-updates the URL via history.pushState and loads it; raw_apps surface a hint to use `wmill app dev` since they don't render here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dev): picker uses homepage tree view with summaries Replace the hand-rolled Button-list picker with a TreeView-style layout that mirrors the Windmill homepage: folder/user tree grouping via `groupItems`, item rows rendered through the shared `Row.svelte` (no actions, no favourites, no link — just the visual), a `SearchItems` fuzzy filter with the same search input styling and placeholder as the homepage, and `group-open:` chevron toggling on native <details>. The CLI's listWorkspacePaths now also reads summaries from each item's metadata (flow.yaml for flows, <script>.script.yaml for scripts) in parallel so the picker shows summaries as the primary row label, same as the homepage. Raw apps have no standard manifest so they show the path only. Additional polish: title shows "<workspace> (local)" instead of generic text, subtitle trimmed, item-wrapper owns the border-b so Row's internal last:border-b-0 doesn't zero it out, summary border gated on group-open: to avoid doubled lines when a folder is collapsed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): wmill dev --no-browser was a no-op Cliffy's `.option("--no-browser", ...)` creates an option named `browser` (boolean, default undefined) that becomes `false` when the flag is passed. The previous code checked `opts.noBrowser`, which Cliffy never populates, so the guard silently no-op'd and the browser always opened. Rename to `browser` and check `=== false` explicitly, matching the `wmill app dev --no-open` convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dev): picker warns when wmill dev server is unreachable Track WS state in Dev.svelte (connecting/open/closed) — 'closed' is set on either the WS error or close event. When closed, the picker replaces the toggle + search + tree with a warning Alert telling the user to run `wmill dev` from the workspace root. Toggle and search are hidden rather than rendered disabled because there's nothing to filter anyway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): rename wmill dev --no-browser to --no-open Match the pre-existing `wmill app dev --no-open` flag. Having `--no-browser` on one dev command and `--no-open` on the other was just an oversight from my earlier change. All three launch.json templates (init, flow new, sync pull) switch to `--no-open`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): wmill init creates root .claude/launch.json for the picker Adds a workspace-root .claude/launch.json so Claude Code can launch `wmill dev` from the project root and land on the file picker (no --path → picker mode). Per-flow and per-raw_app launch.json files are already generated by the existing scans. Skipped (with a gray log) if the file already exists, so the user's customizations are preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): add skipClaudeAssets wmill.yaml flag When `skipClaudeAssets: true` is set in wmill.yaml, all generators that previously wrote Claude-specific assets become no-ops: - writeAiGuidanceFiles skips CLAUDE.md and .claude/skills/ (AGENTS.md is still written — vendor-neutral) - wmill init skips the root .claude/launch.json + per-flow + per-raw_app launch.json scans - wmill sync pull skips the per-flow + per-raw_app launch.json scans - wmill flow new skips the new flow's .claude/launch.json - wmill app new skips the new raw_app's .claude/ folder + launch.json The flag is added to SyncOptions, DEFAULT_SYNC_OPTIONS, and the generated wmill.yaml template (commented out — opt-in). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): wmill init removes existing Claude assets when skipClaudeAssets is set Re-running `wmill init` with `skipClaudeAssets: true` now removes previously-generated Claude assets so the workspace state matches the config. Narrow scope, no confirmation: - per-flow / per-raw_app .claude/launch.json (each parent .claude/ collapsed if empty) - root .claude/launch.json - .claude/skills/ (wholly ours; safe to remove the subtree) - root .claude/ collapsed if empty - CLAUDE.md only if its content matches the default ("Instructions are in @AGENTS.md\n"); otherwise left in place with a note Each removal is logged in yellow under a single gray intro line that prints lazily on the first removal — a clean tree stays silent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): wmill workspace add browser open silently no-ops `open.openApp(open.apps.browser, { arguments: [url] })` resolves its Promise even when the OS-level launch does nothing, so the CLI prints "Opened browser for you" but no tab appears. Same pattern was already fixed in `dev.ts` by commit3272c29c2e— use `open.default(url)`, which delegates to the native URL opener (`open` on macOS, `xdg-open` on Linux, `start` on Windows) and actually rejects on failure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): wmill init workspace prompt no longer duplicates active profile name Cliffy's Select.prompt renders `default: X` as `(X)` next to the question header, which duplicates whichever workspace name the default points to. Drop `default` and instead reorder the list so the active profile is first (cursor-preselected by virtue of position) and append "— active" to its label so the indicator lives where it's contextually relevant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(skills): expand preview-vs-run guidance for write-flow + all write-script-* skills Both `wmill flow preview/run` and `wmill script preview/run` have the same intent split — preview hits the local file, run hits the deployed version, sync push deploys. The skills' "after writing" sections used to terse-list the commands and just say "do not run them yourself", which encouraged the wrong reflex of `sync push` + `run` to "test". Rewrite the section in both `system_prompts/base/flow-base.md` (drives write-flow) and the `script_cli_intro` block in `system_prompts/generate.py` (drives all write-script-<lang>) to: - explicitly list `preview` as the default for local iteration, - spell out the few cases when `run` or `sync push` are appropriate, - offer to test as a one-sentence next step (no multi-option menus), - mark `preview` as safe to run autonomously. Regenerate auto-generated/ + cli/src/guidance/skills.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): wmill dev — clearer mode names and accurate startup messaging - Rename `startLegacyServer` to `startDirectServer`. "Legacy" implied it was on the way out; the two modes (proxy vs direct WS) actually serve different topologies and both stay. Add comments above each section spelling out who they're for: proxy mode for embedders that require a localhost origin (Claude Code preview), direct mode for standalone browser tabs and the VS Code extension iframe. - Replace the stale "Dev server will automatically point to the last script edited locally" log line. Now print path-aware text: - with --path (or auto-detected): "Watching <path> — edits will live -reload in the dev page" - without: "Open the dev page and pick a flow or script to preview — edits will live-reload" plus a hint about --path Mirror the same in proxy mode after the listen callback. - Drop the redundant "Go to <url>" line when --no-open isn't passed (maybeOpenBrowser already prints "Opened browser at <url>"). - Rename "Server listening on port 3001" to "Dev WebSocket listening on ws://localhost:<port>/ws" so the line's purpose is obvious. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): drop per-folder .claude/launch.json generation Stop creating `.claude/launch.json` inside every flow folder, raw_app folder, and at `wmill flow new`/`wmill app new` time. The workspace- root `.claude/launch.json` from `wmill init` stays — it's the picker entry point and the one place where the deterministic "click → preview" UX is high-value. Removed from: - `wmill init` — per-flow + per-raw_app scans - `wmill sync pull` — per-flow + per-raw_app scans (also drops the now-unused `node:fs` mkdirSync/writeFileSync import) - `wmill flow new` — bootstrap no longer scaffolds `.claude/` - `wmill app new` — same; also drops the `.claude/launch.json` lines from the post-create directory listing Skills already give the agent the right CLI commands, so per-folder launch.json was redundant context. Existing files in user projects keep working but won't be regenerated; `wmill init` with `skipClaudeAssets: true` cleans them up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): wmill app new flags + tighten raw-app skill for AI agents `wmill app new` is interactive by default, which hangs forever when an AI agent tries to use it. Add flags so the wizard can be bypassed end-to-end: - `--summary <text>`, `--path <path>`, `--framework <react19|react18| svelte5|vue>` (required for non-interactive) - `--datatable <name>` (opt into the datatable wizard) - `--schema <name>` (creates schema with CREATE SCHEMA IF NOT EXISTS if it doesn't already exist; only valid with --datatable) - `--overwrite` (replace existing directory without prompting) - `--no-open-in-desktop` (suppress the Claude Desktop offer) Mode is auto-detected: providing any of --summary/--path/--framework puts the run into non-interactive mode where the datatable wizard, overwrite prompt, and Claude Desktop prompt all skip silently (or fail fast on conflict instead of waiting for stdin). Each provided flag is validated upfront with a clear error message. Skill side: rewrite `system_prompts/base/raw-app.md`'s "Creating a Raw App" section so the AI agent knows it should run the command itself with flags (not tell the user to run it interactively). Direct the agent to use `AskUserQuestion` with one bundled call to gather any missing summary/path/framework — refuse to invent values, refuse to default. Anti-patterns spelled out explicitly. AGENTS.md template (`cli/src/guidance/core.ts`) had a contradicting line ("MUST ask the user to run wmill app new in its terminal first") that was loaded eagerly into agent context and overrode the skill — replaced with the same agent-driven guidance, pointing to the raw-app skill for the full procedure. Regenerate auto-generated/ + cli/src/guidance/skills.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): per-target preview launch.json + agent uses wmill flow new Refactor the agent's dev/preview workflow: - Drop root .claude/launch.json generation from `wmill init`. Sharing one generic entry across sessions caused preview collisions; agents now add per-target named entries (windmill: <wmill_path>) on demand. - New `preview` skill in system_prompts/base/preview.md. Branches on whether `mcp__Claude_Preview__*` MCP tools are available: with them, add a per-target launch.json entry pinning its own port + --proxy-port + --path + --no-open and invoke the MCP preview tool; without them, start `wmill dev --path <X> --no-open` directly and hand the URL the CLI prints to the user. Never touch launch.json in the direct case. - Agents must run `wmill flow new <path>` themselves to scaffold flows (folder + flow.yaml with the right suffix), parallel to the existing `wmill app new` rule. Missing path/summary trigger AskUserQuestion; no inventing values. - write-flow skill: 4-step Creating a Flow procedure that opens the visual preview *before* editing flow.yaml so the user watches the flow take shape via live reload. - `wmill flow new` always prints the `wmill dev --path <X>` preview hint; drop the Claude CLI/Desktop detection branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(skills): open app preview before editing in raw-app skill Mirrors the flow skill's Step 3 — opening `wmill app dev` via the preview skill before touching App.tsx so the user watches the app take shape via live reload, instead of seeing the finished result at the end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dev): guard WS replaceFlow with lockChanges to prevent echo The postMessage handler at Dev.svelte:306-312 wraps replaceFlow with lockChanges = true (cleared 500 ms later) so the $effect on flowStore.val doesn't immediately re-serialize and re-send the freshly received payload. The WebSocket handler did not, so on the initial flow push (dev.ts:568-574 sends currentLastEdit on connect), the client would echo back to handleFlowRoundTrip, which runs the orphan-file scan. On content equality the write was a no-op, but the scan could still delete files the server did not list. Mirror the same lockChanges/timeout pattern in the WS replaceData handler. Apply to both flow and script paths for symmetry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): correct wmill dev description + gate broadcasts server-side Two related fixes: 1. The 'auto-pushes them to the remote workspace' wording in the wmill dev description was wrong — the command never deploys, it only broadcasts file changes over WS for live preview. Reworded to call this out explicitly and point at 'wmill sync push' for the deploy case. 2. Move the path filter out of the client (Dev.svelte:491-495) and into broadcastChanges. Earlier the filter was client-side with the comment 'server stays a dumb broadcaster' even though commit3c2d5155was titled 'gate dev broadcasts by --path'. Doing the compare server-side aligns the implementation with the commit narrative, cuts WS traffic when --path is set, and keeps the per-tab semantics for the picker (each picker tab still gets the full 'paths' listing on first connect). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): drop dead launch.json cleanup, fix description, narrow orphan scope Three review fixes: 1. cleanupClaudeAssets removed both root and per-folder .claude/launch.json files that this CLI never generates anymore. Per the user's "feature hasn't been released yet" guidance, no migration is needed — drop the dead scan and the root rm. Also drop the now-unused nonDottedPaths argument (and its flowSuffix / rawAppSuffix locals). 2. The skipClaudeAssets description in template.ts listed .claude/launch.json among the assets it skips, but launch.json is no longer generated. Drop it from the description string. 3. The dev round-trip's orphan cleanup deleted any non-dot file in a flow folder that wasn't in extractedPaths — including README.md, fixtures, TODO.md, etc. Restrict the deletion to files whose extension is in a known inline-script set (.ts/.js/.py/.go/.sh/.sql/.ps1/.php/.rs/.java/.cs/.r/.graphql). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli, frontend): dedupe flow suffix helpers, use UI components Five small follow-ups from the PR review: 1. dev.ts already had stripFolderSuffix() but three callsites were reimplementing the same .flow/__flow if-else inline. Add an isFlowFolderName(name) helper next to it and replace the duplicates in startProxyServer's cwd check, the file-watcher localPath strip, and normalizeWmPath. 2. Dev.svelte:866 was a <div onclick> with two svelte-ignore comments for the missing a11y handlers. Replace with a real <button type="button"> — kills the warnings, no visual change. 3. Dev.svelte:1283 was a raw <input type="text"> for the module summary. Replace with the existing <TextInput> component (same one the picker search at :1010 uses), per frontend/CLAUDE.md. 4. Dev.svelte:197 typed relativePaths as any[]; tighten to the actual union (string | [number, string])[] — the python helper returns tuples, the typescript one returns strings. 5. app/new.ts:822 fired exec("open <deeplink>") with no callback, so an OS that refused the URL scheme silently failed and we still logged "Opened in Claude Desktop!". Move the success log inside an exec callback that surfaces the error and prints the deep link for manual opening. Plus a brief comment above parseWatchPath explaining its resync contract (initial load + popstate + explicit pickPath, no generic pushState listener). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): regenerate auto-gen for dev description; drop apostrophe to satisfy parser generate.py:326 extracts .description() with the regex [^"\']+ which bails on either quote type. Commit ff3a8e4ebd's new description had an apostrophe inside double quotes ('wmill sync push'), so the parser saw no description at all and the auto-generated files dropped the line entirely — which is what check-freshness caught on origin/main. Quickest path to green CI: rephrase the description without the inner apostrophe, then regenerate. The generator's regex is the real bug but fixing it is out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): seed app .claude/launch.json before opening Claude Desktop When the user accepts "Open in Claude Desktop?" in wmill app new, write a per-app .claude/launch.json (named "windmill: <appPath>") into the freshly-created app folder before the deep link fires. Entry runs 'wmill app dev --no-open --port ${PORT:-4001}' from the app folder (which is the cwd Claude Desktop opens with), so the user can hit play right away to launch the preview. Skip if .claude/launch.json already exists — never clobber user edits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix toggles positions * fix(dev): gate picker mode on ?local= so VS Code iframe still renders content The VS Code extension iframe loads the dev page without ?path= and without ?local=true. After the picker rework, an empty watchPath flipped pickerMode on, so the page rendered the picker UI even though the extension was sending replaceScript / replaceFlow postMessages — leaving the user stuck on the picker forever. Picker mode only makes sense on the local dev page, where the wmill dev WebSocket can supply the workspace listing. Anywhere else (VS Code iframe, plain remote tab) the picker has no data source and no purpose. Add an isLocalDevPage check so the picker only shows when ?local=true is present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(dev): mirror vscode extension's processFlowMessage round-trip Three changes that bring our wmill dev round-trip into lockstep with the windmill-vscode extension's processFlowMessage in src/extension.ts: 1. New cli/src/commands/dev/pathscript-restore.ts — verbatim port of the extension's src/utils/pathscript-restore.ts. Adds AI-agent tool walking that the previous local copy was missing (flows with PathScript-shaped tools weren't being preserved across round-trip). Header comment makes the cross-repo link explicit. 2. handleFlowRoundTrip rewritten to mirror processFlowMessage step- for-step: reads failure_module + preprocessor_module from the current flow.yaml, passes them to extractCurrentMapping, shares one pathAssigner across all extraction calls, extracts inline scripts from those special modules too, skips writing files whose content starts with !inline (treats as pointer directives), and only rewrites flow.yaml when the serialized YAML actually differs. 3. snapshotPathScripts / tagReplacedPathScripts callsites in loadPaths were passing the FlowFile wrapper instead of FlowFile.value — the helpers walk .modules / .failure_module / .preprocessor_module, which only exist on .value, so PathScript snapshots silently no-op'd on the file-watcher path. Pass .value at all four sites. Deliberate divergence from the extension: orphan-cleanup keeps the INLINE_SCRIPT_EXTS allow-list so README.md / fixtures aren't deleted. The extension's version still over-deletes; that's tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(skills): offer visual preview after create instead of auto-opening Both write-flow and raw-app skills used to instruct the agent to open the visual preview without asking right after wmill flow new / wmill app new, on the rationale that live reload is most useful when the page is already up. In practice this surprised users — opening the dev page has side effects (browser window pop, possibly a launch.json entry under MCP-preview Branch A) that warrant consent. Change Step 3 in both skills from "open it without asking" to "offer it as a one-sentence next step" — same pattern the same skills already use for programmatic wmill flow preview offers. Two then- necessary anti-patterns ("just open it", "open it before editing") are dropped along with the auto-open instruction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): probe both ip stacks before binding wmill dev proxy / app dev port Node's default listen() has platform-dependent dual-stack behaviour. If the requested port is already held on the IPv4 stack, listen() can silently fall back to binding IPv6-only ([::1]:N). The OS then routes new localhost connections to the older IPv4 listener, so the user opens http://localhost:N and sees a stale prior server with no signal that anything is wrong. Bit us in practice: a leftover wmill dev --proxy-port 4000 served traffic for a freshly-started wmill app dev --port 4000. New helper at cli/src/utils/port-probe.ts probes both 0.0.0.0 and :: before binding. On collision it walks upward to the next free port (up to +20) and logs a prominent warning naming the holder when lsof / ss can find it: Port 4000 is already in use (held by PID 91418 `bun`). Using port 4001 instead. Wired into: - wmill dev --proxy-port: the resolved port flows into both proxyServer.listen() and the &port=N parameter in the redirect URL, so they always match. Bind explicitly to 0.0.0.0. - wmill app dev --port: only when the user passed --port explicitly (the default getPort.default(...) path already handles fallback). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dev): pass placeholder via TextInput inputProps not as top-level prop `<TextInput>`'s top-level Props don't include `placeholder` — native input attributes go through the `inputProps` field. The previous `<TextInput placeholder="Summary" .../>` failed `npm run check` with "Object literal may only specify known properties, and '\"placeholder\"' does not exist in type 'Props<\"input\">'.". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nit * fix(cli): sequential port probe + sync dev test regex with renamed log Two CI regressions on test-linux: 1. port-probe parallel race on Linux. isPortFreeOnBothStacks ran the IPv4 and IPv6 binds via Promise.all. On Linux the default is net.ipv6.bindv6only=0, so a bind(::, port) socket also takes the IPv4 stack on the same port. Concurrent v4 + v6 binds then race for v4 — one wins, the other gets EADDRINUSE on a port that is actually free. Walks 20 ports up, all fail the same way, throws, child exits. Tests that fetch http://localhost:port time out at 60s. Doesn't repro on macOS (bindv6only=1 by default — what I tested against). Probe sequentially so each bind fully releases before the next starts. 2. dev_server.test 1 regex out of sync. Commit018dc3861arenamed the startup log from "Server listening on port N" to "Dev WebSocket listening on ws://localhost:N/ws" but didn't update the test, which times out at 30s waiting for the old string. Update the regex to match the current log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update system_prompts/auto-generated/skills/write-script-graphql/SKILL.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix(cli): address dev/app PR review — bugs 1-7 Per code review: 1. app new.ts — wrap claude --session-id exec in try/finally so the spinner setInterval is always cleared. On rejection control jumped to the outer catch and the spinner kept writing \r forever, garbling subsequent output. 2. app new.ts — make --overwrite actually wipe the dir before re-creating. Previously logged "Overwriting" but only skipped the prompt; leftover files from a different framework (e.g. App.tsx from a prior react18 install when re-scaffolding as svelte5) survived and produced a hybrid scaffold. 3. dev/dev.ts — anchor the flow-folder match on path segments. The substring checks (cpath.includes(".flow/") / "__flow/") also fired on names like notes_about__flow_design/readme.md. New isInsideFlowFolder + findFlowFolderPrefix split on "/" and check segment suffixes. Drops the now-unreachable script→flow fallback inside the else branch. 4. dev/dev.ts — direct mode also routes through resolveBindPort so it detects dual-stack collisions like the proxy mode does. Bare getPort only probes one stack, defeating the whole point of port-probe.ts. Also bind to BIND_HOST explicitly. Drops the unused getPort import. 5. dev/dev.ts — normalize opts.path once after mergeConfigWithConfigFile. broadcastChanges compared against a non-normalized opts.path, so --path f/foo/ or --path f/foo.flow silently dropped every broadcast. Also pulls normalizeWmPath to module scope (was a closure inside dev()). 6. dev/dev.ts — guard the initial-state ws.send with readyState === OPEN, matching the other branches' pattern. 7. dev/dev.ts — typo: "givena" → "given a". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): address dev/app PR review — items 8-10 8. dev/dev.ts — derive INLINE_SCRIPT_EXTS from exts so adding a new script language to script.ts auto-extends orphan cleanup. Previously .gql, .nu, .rb were missing — flows using those languages would leave orphaned inline files behind. Excludes .yml because user fixtures commonly use it in flow folders, and leaving a stale .playbook.yml inline script is preferable to deleting a fixture. Keeps .js for hand-written flows that aren't in the exts list. 9. app/new.ts — wrap Claude Desktop install probe + prompt in process.platform === "darwin". The probe (ls /Applications/Claude.app) and the open command both only work on macOS — the explicit guard makes the platform scope grep-able. 10. app/new.ts — switch the deep-link spawn from exec(`open ${shell- escaped url}`) to execFile("open", [deepLink]). sessionId is a UUID and absAppDir is URI-encoded today so the old form was safe, but execFile removes the shell entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli,dev): cubic review — port-probe error semantics, pluralize spacing [2] cli/src/utils/port-probe.ts — distinguish IPv6-unsupported from port collision in isPortFree. Previously every error code returned false, including EAFNOSUPPORT / EADDRNOTAVAIL on the IPv6 probe when the host has no v6 stack at all (IPv4-only containers). resolveBindPort would then walk all 20 ports getting the same error and throw. Treat only EADDRINUSE / EACCES as "not free"; everything else as free. [13] cli/src/commands/app/dev.ts — only probe both stacks when binding to localhost. The dual-stack collision risk is specific to localhost (which resolves to 127.0.0.1 + ::1); for an explicit IPv4 host there's only one stack to worry about, so don't move the user's requested port over a phantom v6 collision. [14] frontend/src/lib/components/Dev.svelte — pluralize already inserts a space between quantity and word, so " item" produced "3 items". Drop the leading space in both call sites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(system_prompts): cubic review — preview args, skill scopes Source changes in base/ + generate.py, then regenerated auto-generated/ via python system_prompts/generate.py. Per cubic review: [4]+[7] generate.py — "pick plausible args from the `main` signature" was language-blind. SQL queries and Bash scripts use $1/$2 positional parameters, not a main(...) signature. Reword to call out both shapes explicitly so the wording survives across all 19 generated language skills (postgresql, bash, mysql, …) instead of just the ones that happen to have main(). [5] base/raw-app.md — the "CLI Commands" table said "Tell the user they can run these commands (do NOT run them yourself)" while the "Creating a Raw App" section above (added in this PR) tells the agent to run `wmill app new` itself. Carve `wmill app new` out of the table and add a one-line note pointing back to the create flow, so the guidance no longer self-contradicts. [10] base/preview.md — "These print a `Go to <url>` line on stdout" was wrong for `wmill app dev`, which prints "🚀 Dev server running at <url>". List both line shapes explicitly and suggest a loose http:// match for URL capture. [12] base/flow-base.md — "regenerate lock files for the flow you modified" misstated the default scope. `wmill generate-metadata` scans scripts, flows, and apps by default (see cli/src/commands/generate-metadata/generate-metadata.ts:71-73). Update wording to call out the default scope and how to narrow it. Also folds the cubic [1] graphql safety wording (originally a one-off edit on the auto-generated file ina895db7) back into generate.py itself, so it survives regeneration and applies to all language skills. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(system_prompts): cubic round 2 — language-specific placeholder syntax Round 1 wording was too narrow: - "$1, $2 placeholders for SQL queries and Bash" was wrong for MySQL (`?`), Snowflake (`?`), MSSQL (`@P1`), BigQuery (`@name`), and PowerShell (which uses `param(...)`, not main()). - The preview-skill URL match said "first `http://...` token" — remote workspaces serve HTTPS, so the regex would miss them. Source-only fixes in generate.py and base/preview.md, then regenerated auto-generated/ via python system_prompts/generate.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(flow): track maxHeight in FlowGraphV2 height effect cubic [3]: updateHeight() reads both minHeight and maxHeight, but the $effect only tracked minHeight. Changing maxHeight alone (e.g. when a parent shrinks the cap during a layout transition) left height frozen at the previously clamped value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(system_prompts): tool-agnostic wording in base/ cubic [11]: system_prompts/README.md says these prompts must NOT contain tool usage instructions. Three base files violated this: - base/flow-base.md (4× AskUserQuestion). Worst offender — leaks into the frontend copilot via FLOW_BASE in prompts.ts (consumed by getFlowPrompt in frontend/src/lib/components/copilot/chat/flow/ core.ts:1287). Frontend has no AskUserQuestion tool, so the wording was both irrelevant and confusing there. - base/raw-app.md (5× AskUserQuestion + 1× mcp__Claude_Preview__). CLI-skill-only but covered by the same scope rule. - base/preview.md (5× mcp__Claude_Preview__). CLI-skill-only, same. Replaced with role descriptions: "ask the user (use a structured- question tool if your runtime has one)" and "a tool that can embed a localhost URL inside the IDE / chat surface". Kept one mention of mcp__Claude_Preview__ in preview.md as an illustrative example, since documentation of one runtime is fine — what's not fine is gating behaviour on a specific tool name. Source-only edits, then regenerated auto-generated/ via python system_prompts/generate.py. Verification: grep -r AskUserQuestion system_prompts/auto-generated/ now returns nothing. The remaining AskUserQuestion refs in cli/src/guidance/core.ts are hand-written CLI-only AGENTS.md content (not part of system_prompts), and Claude Code does have that tool, so those are correctly scoped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(flow): drop .no-splitter CSS hack, use snippets to skip Splitpanes cubic [8]: the previous fix for "top pane is empty in aiagent / noEditor mode" was a CSS rule that hid `:global(.splitpanes__splitter)` inside .no-splitter. That cascaded into nested splitpanes too — the aiagent left/right tabs panel (line 1043), the debug-console editor split (line 877), and the doubly-nested debug panel (line 1472) all lost their resize handles. Refactor the layout instead. Extract top-pane and bottom-pane content as snippets, then conditionally render either: - just the bottom snippet (no Splitpanes wrapper) when the top pane would be empty (aiagent or noEditor), or - the original two-Pane Splitpanes layout otherwise. This removes the splitter at its root rather than hiding it, so nested splitters are unaffected. The bottom Pane's complex bind:size getter/setter (which returned 100 when aiagent) collapses to a simple binding now that the aiagent path no longer goes through the wrapping Pane at all. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nit * fix(flow,preview): cubic round 3 — FlowPathViewer regression + preview skill rewrite [3149192182] FlowModuleComponent.svelte: my last refactor's "aiagent || noEditor" condition stripped the FlowPathViewer for noEditor + type === 'flow', because the top-pane snippet was no longer rendered. The flow-viewer pane is the only thing that *does* show in that mode, so it shouldn't have been collapsed. Tighten the condition to "aiagent || (noEditor && type !== 'flow')". [3149060930] system_prompts/base/preview.md: Branch A detection was too broad — "can embed or open a localhost URL" is strictly weaker than "can read .claude/launch.json and launch a configuration". Only the Claude Desktop / Code MCP integration does the latter; most embedders only do the former. Restructure preview.md around two orthogonal axes: 1. Mode (proxy vs direct) — driven by "does the embedder need a localhost URL?". Direct is the default; proxy is for embedders that sandbox cross-origin loads. 2. Who starts the server — you spawn `wmill dev` yourself, OR a launch.json-aware runtime (currently only the `mcp__Claude_Preview__*` MCP family) launches it on demand. The two compose into four common cases (regular browser tab, generic preview pane, localhost-only preview pane, Claude MCP), each with a clear instruction. The launch.json/MCP machinery is now scoped to a single section gated on actually having that tool in your tool list. Source-only edit in base/preview.md, then regenerated auto-generated/ via python system_prompts/generate.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix dev step display * nit --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
29 KiB
name, description
| name | description |
|---|---|
| write-script-python3 | MUST use when writing Python scripts. |
CLI Commands
Place scripts in a folder.
After writing, tell the user which command fits what they want to do:
wmill script preview <script_path>— default when iterating on a local script. Runs the local file without deploying.wmill script run <path>— runs the script already deployed in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.wmill generate-metadata— generate.script.yamland.lockfiles for the script you modified.wmill sync push— deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
Preview vs run — choose by intent, not habit
If the user says "run the script", "try it", "test it", "does it work" while there are local edits to the script file, use script preview. Do NOT push the script to then script run it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes.
Only use script run when:
- The user explicitly says "run the deployed version" / "run what's on the server".
- There is no local script being edited (you're just invoking an existing script).
Only use sync push when:
- The user explicitly asks to deploy, publish, push, or ship.
- The preview has already validated the change and the user wants it in the workspace.
After writing — offer to test, don't wait passively
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run wmill script preview with sample args?"). Do not present a multi-option menu.
If the user already asked to test/run/try the script in their original request, skip the offer and just execute wmill script preview <path> -d '<args>' directly — pick plausible args from the script's declared parameters. The shape varies by language: main(...) for code languages, the SQL dialect's own placeholder syntax ($1 for PostgreSQL, ? for MySQL/Snowflake, @P1 for MSSQL, @name for BigQuery, etc.), positional $1, $2, … for Bash, param(...) for PowerShell.
wmill script preview does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). wmill sync push and wmill generate-metadata modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
For a visual open-the-script-in-the-dev-page preview (rather than script preview's run-and-print-result), use the preview skill.
Use wmill resource-type list --schema to discover available resource types.
Python
Structure
The script must contain at least one function called main:
def main(param1: str, param2: int):
# Your code here
return {"result": param1, "count": param2}
Do not call the main function. Libraries are installed automatically.
Resource Types
On Windmill, credentials and configuration are stored in resources and passed as parameters to main.
You need to redefine the type of the resources that are needed before the main function as TypedDict:
from typing import TypedDict
class postgresql(TypedDict):
host: str
port: int
user: str
password: str
dbname: str
def main(db: postgresql):
# db contains the database connection details
pass
Important rules:
- The resource type name must be IN LOWERCASE
- Only include resource types if they are actually needed
- If an import conflicts with a resource type name, rename the imported object, not the type name
- Make sure to import TypedDict from typing if you're using it
Imports
Libraries are installed automatically. Do not show installation instructions.
import requests
import pandas as pd
from datetime import datetime
If an import name conflicts with a resource type:
# Wrong - don't rename the type
import stripe as stripe_lib
class stripe_type(TypedDict): ...
# Correct - rename the import
import stripe as stripe_sdk
class stripe(TypedDict):
api_key: str
Windmill Client
Import the windmill client for platform interactions:
import wmill
See the SDK documentation for available methods.
Preprocessor Scripts
For preprocessor scripts, the function should be named preprocessor and receives an event parameter:
from typing import TypedDict, Literal, Any
class Event(TypedDict):
kind: Literal["webhook", "http", "websocket", "kafka", "email", "nats", "postgres", "sqs", "mqtt", "gcp"]
body: Any
headers: dict[str, str]
query: dict[str, str]
def preprocessor(event: Event):
# Transform the event into flow input parameters
return {
"param1": event["body"]["field1"],
"param2": event["query"]["id"]
}
S3 Object Operations
Windmill provides built-in support for S3-compatible storage operations.
import wmill
# Load file content from S3
content: bytes = wmill.load_s3_file(s3object)
# Load file as stream reader
reader: BufferedReader = wmill.load_s3_file_reader(s3object)
# Write file to S3
result: S3Object = wmill.write_s3_file(
s3object, # Target path (or None to auto-generate)
file_content, # bytes or BufferedReader
s3_resource_path, # Optional: specific S3 resource
content_type, # Optional: MIME type
content_disposition # Optional: Content-Disposition header
)
Python SDK (wmill)
Import: import wmill
def worker_has_internal_server() -> bool
def get_mocked_api() -> Optional[dict]
Get the HTTP client instance.
Returns:
Configured httpx.Client for API requests
def get_client() -> httpx.Client
Make an HTTP GET request to the Windmill API.
Args:
endpoint: API endpoint path
raise_for_status: Whether to raise an exception on HTTP errors
**kwargs: Additional arguments passed to httpx.get
Returns:
HTTP response object
def get(endpoint, raise_for_status = True, **kwargs) -> httpx.Response
Make an HTTP POST request to the Windmill API.
Args:
endpoint: API endpoint path
raise_for_status: Whether to raise an exception on HTTP errors
**kwargs: Additional arguments passed to httpx.post
Returns:
HTTP response object
def post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response
Create a new authentication token.
Args:
duration: Token validity duration (default: 1 day)
Returns:
New authentication token string
def create_token(duration = dt.timedelta(days=1)) -> str
Create a script job and return its job id.
.. deprecated:: Use run_script_by_path_async or run_script_by_hash_async instead.
def run_script_async(path: str = None, hash_: str = None, args: dict = None, scheduled_in_secs: int = None) -> str
Create a script job by path and return its job id.
def run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None) -> str
Create a script job by hash and return its job id.
def run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None) -> str
Create a flow job and return its job id.
def run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True) -> str
Run script synchronously and return its result.
.. deprecated:: Use run_script_by_path or run_script_by_hash instead.
def run_script(path: str = None, hash_: str = None, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False) -> Any
Run script by path synchronously and return its result.
def 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) -> Any
Run script by hash synchronously and return its result.
def 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) -> Any
Run a script on the current worker without creating a job.
On agent workers (no internal server), falls back to running a normal
preview job and waiting for the result.
def run_inline_script_preview(content: str, language: str, args: dict = None) -> Any
Wait for a job to complete and return its result.
Args:
job_id: ID of the job to wait for
timeout: Maximum time to wait (seconds or timedelta)
verbose: Enable verbose logging
cleanup: Register cleanup handler to cancel job on exit
assert_result_is_not_none: Raise exception if result is None
Returns:
Job result when completed
Raises:
TimeoutError: If timeout is reached
Exception: If job fails
def wait_job(job_id, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False)
Cancel a specific job by ID.
Args:
job_id: UUID of the job to cancel
reason: Optional reason for cancellation
Returns:
Response message from the cancel endpoint
def cancel_job(job_id: str, reason: str = None) -> str
Cancel currently running executions of the same script.
def cancel_running() -> dict
Get job details by ID.
Args:
job_id: UUID of the job
Returns:
Job details dictionary
def get_job(job_id: str) -> dict
Get the root job ID for a flow hierarchy.
Args:
job_id: Job ID (defaults to current WM_JOB_ID)
Returns:
Root job ID
def get_root_job_id(job_id: str | None = None) -> dict
Get an OIDC JWT token for authentication to external services.
Args:
audience: Token audience (e.g., "vault", "aws")
expires_in: Optional expiration time in seconds
Returns:
JWT token string
def get_id_token(audience: str, expires_in: int | None = None) -> str
Get the status of a job.
Args:
job_id: UUID of the job
Returns:
Job status: "RUNNING", "WAITING", or "COMPLETED"
def get_job_status(job_id: str) -> JobStatus
Get the result of a completed job.
Args:
job_id: UUID of the completed job
assert_result_is_not_none: Raise exception if result is None
Returns:
Job result
def get_result(job_id: str, assert_result_is_not_none: bool = True) -> Any
Get a variable value by path.
Args:
path: Variable path in Windmill
Returns:
Variable value as string
def get_variable(path: str) -> str
Set a variable value by path, creating it if it doesn't exist.
Args:
path: Variable path in Windmill
value: Variable value to set
is_secret: Whether the variable should be secret (default: False)
def set_variable(path: str, value: str, is_secret: bool = False) -> None
Get a resource value by path.
Args:
path: Resource path in Windmill
none_if_undefined: Return None instead of raising if not found
interpolated: if variables and resources are fully unrolled
Returns:
Resource value dictionary or None
def get_resource(path: str, none_if_undefined: bool = False, interpolated: bool = True) -> dict | None
Set a resource value by path, creating it if it doesn't exist.
Args:
value: Resource value to set
path: Resource path in Windmill
resource_type: Resource type for creation
def set_resource(value: Any, path: str, resource_type: str)
List resources from Windmill workspace.
Args:
resource_type: Optional resource type to filter by (e.g., "postgresql", "mysql", "s3")
page: Optional page number for pagination
per_page: Optional number of results per page
Returns:
List of resource dictionaries
def list_resources(resource_type: str = None, page: int = None, per_page: int = None) -> list[dict]
Set the workflow state.
Args:
value: State value to set
path: Optional state resource path override.
def set_state(value: Any, path: str | None = None) -> None
Get the workflow state.
Args:
path: Optional state resource path override.
Returns:
State value or None if not set
def get_state(path: str | None = None) -> Any
Set job progress percentage (0-99).
Args:
value: Progress percentage
job_id: Job ID (defaults to current WM_JOB_ID)
def set_progress(value: int, job_id: Optional[str] = None)
Get job progress percentage.
Args:
job_id: Job ID (defaults to current WM_JOB_ID)
Returns:
Progress value (0-100) or None if not set
def get_progress(job_id: Optional[str] = None) -> Any
Set the user state of a flow at a given key
def set_flow_user_state(key: str, value: Any) -> None
Get the user state of a flow at a given key
def get_flow_user_state(key: str) -> Any
Get the Windmill server version.
Returns:
Version string
def version()
Convenient helpers that takes an S3 resource as input and returns the settings necessary to
initiate an S3 connection from DuckDB
def get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None
Convenient helpers that takes an S3 resource as input and returns the settings necessary to
initiate an S3 connection from Polars
def get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings
Convenient helpers that takes an S3 resource as input and returns the settings necessary to
initiate an S3 connection using boto3
def get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings
Load a file from the workspace s3 bucket and returns its content as bytes.
'''python
from wmill import S3Object
s3_obj = S3Object(s3="/path/to/my_file.txt")
my_obj_content = client.load_s3_file(s3_obj)
file_content = my_obj_content.decode("utf-8")
'''
def load_s3_file(s3object: S3Object | str, s3_resource_path: str | None) -> bytes
Load a file from the workspace s3 bucket and returns the bytes stream.
'''python
from wmill import S3Object
s3_obj = S3Object(s3="/path/to/my_file.txt")
with wmill.load_s3_file_reader(s3object, s3_resource_path) as file_reader:
print(file_reader.read())
'''
def load_s3_file_reader(s3object: S3Object | str, s3_resource_path: str | None) -> BufferedReader
Write a file to the workspace S3 bucket
'''python
from wmill import S3Object
s3_obj = S3Object(s3="/path/to/my_file.txt")
# for an in memory bytes array:
file_content = b'Hello Windmill!'
client.write_s3_file(s3_obj, file_content)
# for a file:
with open("my_file.txt", "rb") as my_file:
client.write_s3_file(s3_obj, my_file)
'''
def 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
Permanently delete a file from the workspace S3 bucket.
'''python
from wmill import S3Object
s3_obj = S3Object(s3="/path/to/my_file.txt")
client.delete_s3_object(s3_obj)
'''
def delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = None) -> None
Sign S3 objects for use by anonymous users in public apps.
Args:
s3_objects: List of S3 objects to sign
Returns:
List of signed S3 objects
def sign_s3_objects(s3_objects: list[S3Object | str]) -> list[S3Object]
Sign a single S3 object for use by anonymous users in public apps.
Args:
s3_object: S3 object to sign
Returns:
Signed S3 object
def sign_s3_object(s3_object: S3Object | str) -> S3Object
Generate presigned public URLs for an array of S3 objects.
If an S3 object is not signed yet, it will be signed first.
Args:
s3_objects: List of S3 objects to sign
base_url: Optional base URL for the presigned URLs (defaults to WM_BASE_URL)
Returns:
List of signed public URLs
Example:
>>> s3_objs = [S3Object(s3="/path/to/file1.txt"), S3Object(s3="/path/to/file2.txt")]
>>> urls = client.get_presigned_s3_public_urls(s3_objs)
def get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None) -> list[str]
Generate a presigned public URL for an S3 object.
If the S3 object is not signed yet, it will be signed first.
Args:
s3_object: S3 object to sign
base_url: Optional base URL for the presigned URL (defaults to WM_BASE_URL)
Returns:
Signed public URL
Example:
>>> s3_obj = S3Object(s3="/path/to/file.txt")
>>> url = client.get_presigned_s3_public_url(s3_obj)
def get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None) -> str
Get the current user information.
Returns:
User details dictionary
def whoami() -> dict
Get the current user information (alias for whoami).
Returns:
User details dictionary
def user() -> dict
Get the state resource path from environment.
Returns:
State path string
def state_path() -> str
Get the workflow state.
Returns:
State value or None if not set
def state() -> Any
Set the state in the shared folder using pickle
def set_shared_state_pickle(value: Any, path: str = 'state.pickle') -> None
Get the state in the shared folder using pickle
def get_shared_state_pickle(path: str = 'state.pickle') -> Any
Set the state in the shared folder using pickle
def set_shared_state(value: Any, path: str = 'state.json') -> None
Get the state in the shared folder using pickle
def get_shared_state(path: str = 'state.json') -> None
Get URLs needed for resuming a flow after suspension.
Args:
approver: Optional approver name
flow_level: If True, generate resume URLs for the parent flow instead of the
specific step. This allows pre-approvals that can be consumed by any later
suspend step in the same flow.
Returns:
Dictionary with approvalPage, resume, and cancel URLs
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.
[Enterprise Edition Only] To include form fields in the Slack approval request, use the "Advanced -> Suspend -> Form" functionality.
Learn more at: https://www.windmill.dev/docs/flows/flow_approval#form
:param slack_resource_path: The path to the Slack resource in Windmill.
:type slack_resource_path: str
:param channel_id: The Slack channel ID where the approval request will be sent.
:type channel_id: str
:param message: Optional custom message to include in the Slack approval request.
:type message: str, optional
:param approver: Optional user ID or name of the approver for the request.
:type approver: str, optional
:param default_args_json: Optional dictionary defining or overriding the default arguments for form fields.
:type default_args_json: dict, optional
:param dynamic_enums_json: Optional dictionary overriding the enum default values of enum form fields.
:type dynamic_enums_json: dict, optional
:raises Exception: If the function is not called within a flow or flow preview.
:raises Exception: If the required flow job or flow step environment variables are not set.
:return: None
Usage Example:
>>> client.request_interactive_slack_approval(
... slack_resource_path="/u/alex/my_slack_resource",
... channel_id="admins-slack-channel",
... message="Please approve this request",
... approver="approver123",
... default_args_json={"key1": "value1", "key2": 42},
... dynamic_enums_json={"foo": ["choice1", "choice2"], "bar": ["optionA", "optionB"]},
... )
Notes:
- This function must be executed within a Windmill flow or flow preview.
- The function checks for required environment variables (WM_FLOW_JOB_ID, WM_FLOW_STEP_ID) to ensure it is run in the appropriate context.
def 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
Get email from workspace username
This method is particularly useful for apps that require the email address of the viewer.
Indeed, in the viewer context WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app.
def username_to_email(username: str) -> str
Send a message to a Microsoft Teams conversation with conversation_id, where success is used to style the message
def send_teams_message(conversation_id: str, text: str, success: bool = True, card_block: dict = None)
Get a DataTable client for SQL queries.
Args:
name: Database name (default: "main")
Returns:
DataTableClient instance
def datatable(name: str = 'main')
Get a DuckLake client for DuckDB queries.
Args:
name: Database name (default: "main")
Returns:
DucklakeClient instance
def ducklake(name: str = 'main')
def init_global_client(f)
def deprecate(in_favor_of: str)
Get the current workspace ID.
Returns:
Workspace ID string
def get_workspace() -> str
def get_version() -> str
Run a script synchronously by hash and return its result.
Args:
hash: Script hash
args: Script arguments
verbose: Enable verbose logging
assert_result_is_not_none: Raise exception if result is None
cleanup: Register cleanup handler to cancel job on exit
timeout: Maximum time to wait
Returns:
Script result
def 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) -> Any
Run a script synchronously by path and return its result.
Args:
path: Script path
args: Script arguments
verbose: Enable verbose logging
assert_result_is_not_none: Raise exception if result is None
cleanup: Register cleanup handler to cancel job on exit
timeout: Maximum time to wait
Returns:
Script result
def 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) -> Any
Convenient helpers that takes an S3 resource as input and returns the settings necessary to
initiate an S3 connection from DuckDB
def duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings
Convenient helpers that takes an S3 resource as input and returns the settings necessary to
initiate an S3 connection from Polars
def polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings
Convenient helpers that takes an S3 resource as input and returns the settings necessary to
initiate an S3 connection using boto3
def boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings
Get the state resource path from environment.
Returns:
State path string
def get_state_path() -> str
Parse resource syntax from string.
def parse_resource_syntax(s: str) -> Optional[str]
Parse S3 object from string or S3Object format.
def parse_s3_object(s3_object: S3Object | str) -> S3Object
Parse variable syntax from string.
def parse_variable_syntax(s: str) -> Optional[str]
Append a text to the result stream.
Args:
text: text to append to the result stream
def append_to_result_stream(text: str) -> None
Stream to the result stream.
Args:
stream: stream to stream to the result stream
def stream_result(stream) -> None
Execute a SQL query against the DataTable.
Args:
sql: SQL query string with $1, $2, etc. placeholders
*args: Positional arguments to bind to query placeholders
Returns:
SqlQuery instance for fetching results
def query(sql: str, *args) -> SqlQuery
Execute query and fetch results.
Args:
result_collection: Optional result collection mode
Returns:
Query results
def fetch(result_collection: str | None = None)
Execute query and fetch first row of results.
Returns:
First row of query results
def fetch_one()
Execute query and fetch first row of results. Return result as a scalar value.
Returns:
First row of query result as a scalar value
def fetch_one_scalar()
Execute query and don't return any results.
def execute()
DuckDB executor requires explicit argument types at declaration
These types exist in both DuckDB and Postgres
Check that the types exist if you plan to extend this function for other SQL engines.
def infer_sql_type(value) -> str
def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
Decorator that marks a function as a workflow task.
Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2
(async, checkpoint/replay) modes:
- v2 (inside @workflow): dispatches as a checkpoint step.
- v1 (WM_JOB_ID set, no @workflow): dispatches via HTTP API.
- Standalone: executes the function body directly.
Usage::
@task
async def extract_data(url: str): ...
@task(path="f/external_script", timeout=600, tag="gpu")
async def run_external(x: int): ...
def 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)
Create a task that dispatches to a separate Windmill script.
Usage::
extract = task_script("f/data/extract", timeout=600)
@workflow
async def main():
data = await extract(url="https://...")
def 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)
Create a task that dispatches to a separate Windmill flow.
Usage::
pipeline = task_flow("f/etl/pipeline", priority=10)
@workflow
async def main():
result = await pipeline(input=data)
def 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)
Decorator marking an async function as a workflow-as-code entry point.
The function must be deterministic: given the same inputs it must call
tasks in the same order on every replay. Branching on task results is fine
(results are replayed from checkpoint), but branching on external state
(current time, random values, external API calls) must use step() to
checkpoint the value so replays see the same result.
def workflow(func)
Execute fn inline and checkpoint the result.
On replay the cached value is returned without re-executing fn.
Use for lightweight deterministic operations (timestamps, random IDs,
config reads) that should not incur the overhead of a child job.
async def step(name: str, fn)
Server-side sleep — suspend the workflow for the given duration without holding a worker.
Inside a @workflow, the parent job suspends and auto-resumes after seconds.
Outside a workflow, falls back to asyncio.sleep.
async def sleep(seconds: int)
Suspend the workflow and wait for an external approval.
Use get_resume_urls() (wrapped in step()) to obtain
resume/cancel/approval URLs before calling this function.
Returns a dict with value (form data), approver, and approved.
Args:
timeout: Approval timeout in seconds (default 1800).
form: Optional form schema for the approval page.
self_approval: Whether the user who triggered the flow can approve it (default True).
Example::
urls = await step("urls", lambda: get_resume_urls())
await step("notify", lambda: send_email(urls["approvalPage"]))
result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
Process items in parallel with optional concurrency control.
Each item is processed by calling fn(item), which should be a @task.
Items are dispatched in batches of concurrency (default: all at once).
Example::
@task
async def process(item: str):
...
results = await parallel(items, process, concurrency=5)
async def parallel(items, fn, concurrency: Optional[int] = None)
Commit Kafka offsets for a trigger with auto_commit disabled.
Args:
trigger_path: Path to the Kafka trigger (from event['wm_trigger']['trigger_path'])
topic: Kafka topic name (from event['topic'])
partition: Partition number (from event['partition'])
offset: Message offset to commit (from event['offset'])
def commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None