* feat: teach the AI the raw-app job bindings and the draft/deployed split Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: scope the raw-app deploy advice to the referenced item, and stop kind-conversion from stranding fields The draft/deployed guidance added in the previous commit was read as "deploy the app too": the agent asked for both the flow and the app and routed a one-item dependency through the review-and-deploy page. Only the referenced flow or script has to exist deployed — the preview runs the app's draft — so the prompts, the `write_app_runnable` warning and the testing rule now say to offer that one deploy and leave the app a draft. `buildPersistedRunnable` spread the existing runnable when rewriting it, so converting a path runnable to inline left `runType`/`path` behind (and the reverse left `inlineScript`). `isRunnableByName` matches the inline branch first, so an app "wired to a flow" silently ran stale inline code. `test_run_app_runnable` now fills ctx-bound inputs with `$ctx:<prop>` the way RawAppBackgroundRunner does, so a ctx argument no longer arrives missing. The SDK-reference rationale claimed WM_TOKEN may be unset, that a missing base URL falls back to localhost, and that a job token is scoped enough to 403 a hand-rolled REST call. None of the three is true, and it shipped to every write-script prompt; the text now only says the client configures itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address review round on the raw-app AI instructions The eval case could pass on the exact answer it exists to reject. Every `requiredMentionsAnyOf` alternative but one was flow-agnostic, so "the app must be deployed" satisfied "must be deployed". All alternatives now name the flow, and a unit test pins that the app-only phrasing fails. `instanceLine` asserted "self-hosted Community Edition" outside the browser, where `isCloudHosted()` reads false and the license store is unset — so every global eval was told that regardless of what it pointed at. It is now emitted only under BROWSER. `assistantExpect.forbiddenMentions` defaulted a missing `assistantText` to "", which passes every entry forever on a mode whose runner does not report it. It now fails with that as the reason. `buildPersistedRunnable` carried `schema` across a retarget, so a path runnable pointed at a new flow kept the previous item's schema and `genWmillTs` typed `backend.<key>(args)` from the wrong inputs. It survives only while kind and path both match. The SDK header claimed "a function that is not listed below does not exist". `windmill-client` also exports the generated services, and the Python client exposes `Windmill.get`/`.post`, so an endpoint without a helper had no legal move. Each language now names its own escape hatch. `getAppInstructions` said the attached reference carries the TypeScript SDK even when `language: "python3"` had swapped in the Python one — on the very sentence telling the model to make that call. The kind-conversion comment claimed a hybrid runnable "silently runs stale inline code". It does not: `isRunnableByName`, `isRunnableByPath`, `convertPersistedToBackendRunnable` and `rawAppPolicy.processRunnable` all dispatch on `type` alone. The leftovers contradict the runnable's kind rather than override it, which is what the comment now says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address round-2 review nits on the raw-app AI instructions `flow is deployed` was satisfied both by "once the flow is deployed, the button works" and by a hallucinated "done — the flow is deployed", which eval mode makes impossible and the drafts-only judge cannot see. Every alternative now states an outstanding obligation, and two more real phrasings ("will need to be deployed") are accepted so a correct answer is not failed on wording. Condenses the three comment blocks that ran past the four-line limit in AGENTS.md, and drops two claims inside them that no longer hold: the `testRunAppRunnable` doc said it runs a runnable the way the app's own frontend does (it is the editor preview, which a deployed app's stored policy does not match), and `undeployedRunnableTargets` described its argument as the write tool's raw input when the call site passes the persisted runnable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: report the real cause when a test run fails, and label the app-runnable card Driving `test_run_app_runnable` in a live session surfaced two defects the API-level check could not see. `executeTestRun` built its failure message from `error.message`, which the generated client leaves as the bare status text while the server's message sits in `body`. A path runnable aimed at an undeployed flow reported "Not Found" instead of "Not found: flow not found at name u/admin/current_time" — dropping the one diagnostic the run exists to produce. `formatToolError`, in the same file and written for exactly this, now does it. This also applies to test_run_script and test_run_flow, which had the same loss. The completion card read "Flow test completed successfully" for an app runnable, because `contextName` doubles as the jobs-tray kind and a path runnable pointing at a flow really does queue a flow job. A `completionName` override now names what ran without changing the kind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin the deploy expectation against wrong answers, not just correct ones `deploying the flow` was satisfied by "done deploying the flow" — a deploy the agent only claims to have made, which eval mode makes impossible and the drafts-only judge cannot see. Replaced with the prospective forms, and dropped the same reading from the workflow variant. Three review rounds each found this same class of hole in the phrasing list, so the list is now exercised against the wrong answers themselves rather than eyeballed: naming the app as what needs deploying, claiming the deploy is already done, claiming to have deployed the flow, and saying nothing about deploying all have to fail, while four real correct phrasings have to pass. The test reads the case out of global.yaml, so a future edit to the alternatives is checked by it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: drop the tense-neutral deploy alternatives and cover completed claims A gerund after a preposition carries no tense, so `before`/`after`/`by deploying the flow` all match a deploy the agent only claims to have made ("after deploying the flow, I clicked the button and it returns the greeting") just as the bare gerund did. All three are gone rather than swapped for whichever reads least badly, and the two completed-deploy phrasings are now negative fixtures. The remaining alternatives are imperative or obligational, which a claim of having already deployed cannot satisfy. Condenses the two comments this list carries: the YAML block to four lines, and the test's rationale to the durable constraint about substring matching. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: encrypt sensitive inputs when test-running an app runnable `test_run_app_runnable` sent `force_viewer_static_fields` but not `force_viewer_sensitive_inputs`, which every other preview path derives from the runnable's `sensitive` user fields. That list is the only thing driving the encryption loop in apps.rs, so testing a runnable with a sensitive input wrote the real value into the job's args in plaintext, readable by anyone with run access to the workspace. Verified against a running EE instance. With the list, `api_key` is stored as `$encrypted:mvqtSRI9…` and the sentinel appears nowhere in the job record; without it, the sentinel is readable in run details. A non-sensitive field is left plaintext either way. The tool claims parity with the editor preview, so it uses that same filter (`type == 'user' && sensitive`) and omits the field entirely when empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
12 KiB
Windmill Raw Apps
Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables.
App shape
A raw app has three logical parts:
- Frontend — bundled with esbuild from
index.tsxas the entrypoint. Files include the entrypoint, components (App.tsx), styles, etc. - Backend runnables — server-side scripts the frontend calls, each addressed by a unique key.
- Data — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.
Frontend
Entrypoint
The entrypoint is index.tsx for React and index.ts for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the mount entrypoint: the preview executes the bundle against an empty <div id="root"> and auto-renders nothing, so the entrypoint must mount a top-level App itself. Keep the UI in App.tsx / App.svelte / App.vue and keep the entrypoint as the mount shim.
React (index.tsx):
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(<App />)
Svelte (index.ts): mount(App, { target: document.getElementById('root')! }). Vue (index.ts): createApp(App).mount('#root').
Never replace the entrypoint with a bare component (export default function App() { ... } and no mount call). A component that is defined but never mounted renders a blank screen with no error thrown — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts App into #root.
Always begin every React file (.tsx/.jsx) that uses JSX with import React from 'react'. esbuild uses the classic JSX transform, so React must be in scope wherever JSX appears — a missing import compiles fine but throws React is not defined at runtime, leaving a blank screen.
Generated bindings (wmill.d.ts / wmill.ts)
The frontend imports a generated module that mirrors the backend runnables. Never write to it directly — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.
Calling backend runnables
Import the generated bindings and call the runnable like a function. ./wmill is the only way the frontend reaches anything server-side — datatables, workspace items, external services. Never fetch the Windmill API from frontend code: the bundle holds no token and builds no API URL.
| Export | Resolves to | Use it for |
|---|---|---|
backend.<key>(args) |
the runnable's result | the default — run and wait |
backendAsync.<key>(args) |
the job id (a string) | long-running work you want to track |
waitJob(jobId) |
the job's result (rejects if the job failed) | awaiting a backendAsync job |
getJob(jobId) |
a Job ({ type, success, result, duration_ms, ... }) |
polling status without blocking |
streamJob(jobId, onUpdate?) |
the final result, calling onUpdate per chunk |
showing output as it is produced |
Run and wait — the common case:
import { backend } from './wmill';
const user = await backend.get_user({ user_id: '123' });
Start a long job, then await it:
import { backendAsync, waitJob } from './wmill';
const jobId = await backendAsync.run_report({ month: '2026-08' }); // a string
const report = await waitJob(jobId); // the result itself
Or poll it without blocking, to render progress:
import { getJob } from './wmill';
const job = await getJob(jobId);
if (job.type === 'CompletedJob') setReport(job.result);
backendAsync resolves a job id and nothing else — guard on it before storing or polling. A poll loop started on an undefined id never completes and shows as a row stuck "running" forever:
const jobId = await backendAsync.run_report(args);
if (!jobId) throw new Error('run_report did not start a job');
Never hand-write a job-polling runnable. A backend runnable that calls jobs/list, or that returns getResultMaybe(...) for the frontend to poll, reimplements backendAsync + waitJob / getJob / streamJob — and it is what leads to guessing at base URLs and tokens.
Keeping data out of recorded demos
An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with data-wm-no-record — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:
<label data-wm-no-record>
Customer SSN <input value={ssn} onChange={onSsn} />
</label>
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
Backend runnables
Each runnable has a unique key (used to call it from the frontend) and one of four types:
| Type | What it is |
|---|---|
inline |
Custom code stored on the app itself. Most common for app-specific logic. |
script |
Reference to an existing workspace script by path. |
flow |
Reference to an existing workspace flow by path. |
hubscript |
Reference to a hub script by path. |
Inline runnables
Inline runnables carry their own source code. For file-based raw apps, the runnable language is determined by the backend file extension. The script must expose a main function as its entrypoint.
TypeScript example (backend/get_user.ts):
import * as wmill from 'windmill-client';
export async function main(user_id: string) {
const sql = wmill.datatable();
const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();
return user;
}
Python example (backend/get_user.py):
import wmill
def main(user_id: str):
db = wmill.datatable()
user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()
return user
The wmill client is already authenticated
An inline runnable runs as an ordinary Windmill job. import * as wmill from 'windmill-client' (TypeScript) and import wmill (Python) are already pointed at this instance and this workspace — there is nothing to configure.
Don't read WM_TOKEN or BASE_INTERNAL_URL and build an API URL to fetch. The client's own setClient already reads exactly those, and it also sets the credentials mode a raw app needs (WM_RAW_APP suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with Access-Control-Allow-Origin: *). Rebuilding that by hand drops the parts you can't see. Use wmill.* for everything Windmill, and fetch only for third-party APIs.
Prefer the wmill functions that appear in the SDK reference; for an endpoint none of them covers, the generated service classes (JobService, ScriptService, ...) are importable from windmill-client. What is not available is a name you guessed at: getBaseUrl and getWorkspaceToken are inventions, not API.
Path runnables (script / flow / hubscript)
When type is script, flow, or hubscript, the runnable just stores a path to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface.
Draft code vs deployed code
This decides whether an app works before anything is deployed:
- Inline runnables run the app's current code. The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.
- Path runnables (
script/flow/hubscript) run the DEPLOYED item at that path. So dowmill.runFlow,wmill.runFlowAsyncandwmill.runScriptByPathcalled from inside a runnable. A draft — including a draft you just created — does not exist for them.
So an app wired to a flow you just wrote does nothing until that flow is deployed. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.
That makes the fix a one-item deploy, not a release. Offer to deploy exactly the referenced flow or script and leave the app a draft the user keeps testing in the preview — do not push the whole change set through the review-and-deploy page, and do not ask the user to deploy the app, unless they said they want to ship it.
Do NOT quietly reimplement the flow inside an inline runnable to dodge the deployment: that leaves the user with two copies of the same logic and an app that ignores the flow they asked for. Inline the logic only when the user actually wants it inline.
Prefer a path runnable of type flow over an inline runnable that calls wmill.runFlowAsync. The path runnable gives the frontend the flow's real input schema and works with backend / backendAsync / waitJob like any other runnable; a hand-written wrapper gives up all of that.
Static inputs
staticInputs is an optional Record<string, any> for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller.
Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the wmill client; the frontend never queries them directly.
Critical rules
- Whitelisted tables only: a runnable can only query tables listed in the app's
data.tablesconfig. Tables not in this list are not accessible. - Add tables before using: queries against unlisted tables fail at runtime. When you introduce a new table, register it in
data.tablesfirst. - Use the configured datatable/schema: the app's
dataconfig sets the default datatable and schema; reference them consistently across runnables.
Querying in TypeScript (Bun/Deno)
import * as wmill from 'windmill-client';
export async function main(user_id: string) {
const sql = wmill.datatable(); // Or: wmill.datatable('other_datatable')
// Parameterized queries (safe from SQL injection)
const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();
const users = await sql`SELECT * FROM users WHERE active = ${true}`.fetch();
// Insert/Update
await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`;
await sql`UPDATE users SET name = ${newName} WHERE id = ${user_id}`;
return user;
}
Querying in Python
import wmill
def main(user_id: str):
db = wmill.datatable() # Or: wmill.datatable('other_datatable')
# Use $1, $2, etc. for parameters
user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()
users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()
# Insert/Update
db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email)
db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id)
return user
Best Practices
- Check existing tables before creating new ones — reuse beats schema growth.
- Use parameterized queries — never concatenate user input into SQL.
- Keep runnables focused — one function per runnable; small surface area.
- Use descriptive keys —
get_user, nota. - Always whitelist tables — adding a runnable that queries a new table requires the table to be in
data.tablesfirst. - Mark sensitive UI with
data-wm-no-record— it is what keeps that data out of a recorded demo; passwords are handled for you. - Reach for
backendAsync+waitJobfor long work — never a hand-written job-polling runnable. - Deploy what a path runnable points at — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.