raw-app
GitHub指导 AI Agent 使用 wmill CLI 自动创建 Windmill Raw App。需收集摘要、路径和框架信息,以非交互模式执行命令,支持可选的数据表和覆盖参数,避免手动操作导致的挂起。
Trigger Scenarios
Install
npx skills add windmill-labs/windmill --skill raw-app -g -y
SKILL.md
Frontmatter
{
"name": "raw-app",
"description": "MUST use when creating raw apps."
}
Windmill Raw Apps — CLI workflow
This guide covers raw apps from the terminal: scaffolding via wmill app new, the on-disk layout, and the file-based conventions the CLI uses to represent backend runnables and data table configuration. The platform shape (how a raw app behaves at runtime — frontend bundling, runnable types, datatable SDK calls) is covered in the companion authoring guide.
Creating a Raw App
You — the AI agent — create the app yourself by running wmill app new with the right flags. Do NOT tell the user to "run wmill app new and follow the prompts" or wait for them to do it. The bare wmill app new is an interactive wizard that hangs waiting for stdin in any non-TTY context (which includes you). Always pass flags.
Step 1 — Gather the three required values by asking the user
You need three things to run the command:
- summary — a short description of the app
- path — the windmill path, e.g.
f/folder/my_apporu/username/my_app - framework — one of
react19(recommended),react18,svelte5,vue
If the user's request did not supply every one of these explicitly, ask. Do not guess values, do not invent paths, do not pick a framework on the user's behalf, do not "just use react19 because it's the default".
Use whichever interactive question facility your runtime provides — a structured multi-choice tool if available, otherwise plain chat — and group all missing fields into a single round-trip so the user answers them at once:
- For
framework— multiple-choice with the four allowed values; markreact19as(Recommended)and put it first. - For
summaryandpath— provide one or two example values as multiple-choice options (the user can pick "Other" to type a free-form answer).
Only proceed once you have concrete values for all three. If the user replies with something ambiguous, ask again rather than guessing.
Step 2 — Run the command yourself
Once you have summary + path + framework, run it:
wmill app new \
--summary "Customer dashboard" \
--path f/sales/dashboard \
--framework react19
That's the minimum. The datatable wizard and the "Open in Claude Desktop?" prompt are skipped silently because passing any of --summary/--path/--framework puts the command in non-interactive mode.
Optional flags
Layer these in only when the user asked for them:
| Flag | When to add it |
|---|---|
--datatable <name> |
The user wants this app wired to a specific Windmill datatable. Without it, the app is created with no datatable. |
--schema <name> |
Together with --datatable. Creates the schema with CREATE SCHEMA IF NOT EXISTS if it doesn't already exist. |
--overwrite |
The target directory already exists and the user said it's OK to replace. Without it, non-interactive mode aborts with an error so you don't clobber existing work. |
--no-open-in-desktop |
Already implied in non-interactive mode; only needed if you're somehow running interactively. |
Step 3 — Offer the visual preview
After wmill app new and any initial edits to App.tsx / index.tsx, offer to open the visual preview as a one-sentence next step (e.g. "Want me to open the visual preview?"). Don't auto-open — opening the dev page has side effects (browser window, possibly a launch.json entry when an embedded preview tool is in play) the user should consent to.
For apps the preview command runs from the app folder (cd <app_path>__raw_app && wmill app dev …); the preview skill picks the proxy vs direct branch based on whether the runtime exposes a tool that can embed a localhost URL. If the user already asked to see/preview/visualize the app in their original request, skip the offer and just invoke the skill.
Anti-patterns to avoid
- ❌ Running
wmill app newwith no flags (the prompt will hang). - ❌ Telling the user to "run
wmill app newand follow the prompts" — that's a step backwards from what you can do directly. - ❌ Inventing a path/summary/framework instead of asking the user.
- ❌ Defaulting to
react19because the user didn't say — even sensible defaults must be confirmed. - ❌ Passing
--overwriteautomatically when the directory exists — confirm with the user first.
Interactive (only when a human is at the terminal)
wmill app new
This is the wizard. It only works when run by a human in a real terminal. Don't call it this way from an agent.
On-disk app layout
my_app__raw_app/
├── AGENTS.md # AI agent instructions (auto-generated)
├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh)
├── raw_app.yaml # App configuration (summary, path, data settings)
├── index.tsx # Frontend entry point
├── App.tsx # Main React/Svelte/Vue component
├── index.css # Styles
├── package.json # Frontend dependencies
├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT)
├── backend/ # Backend runnables (server-side scripts)
│ ├── <id>.<ext> # Code file (e.g., get_user.ts)
│ ├── <id>.yaml # Optional: config for fields, or to reference existing scripts
│ └── <id>.lock # Lock file (run 'wmill generate-metadata' to create/update)
└── sql_to_apply/ # SQL migrations (dev only, not synced)
└── *.sql # SQL files to apply via dev server
Backend runnables on disk
Add a code file to the backend/ folder:
backend/<id>.<ext>
The runnable ID is the filename without extension. For example, get_user.ts creates a runnable with ID get_user.
Supported languages (extension-driven)
| Language | Extension | Example |
|---|---|---|
| TypeScript | .ts |
myFunc.ts |
| TypeScript (Bun) | .bun.ts |
myFunc.bun.ts |
| TypeScript (Deno) | .deno.ts |
myFunc.deno.ts |
| Python | .py |
myFunc.py |
| Go | .go |
myFunc.go |
| Bash | .sh |
myFunc.sh |
| PowerShell | .ps1 |
myFunc.ps1 |
| PostgreSQL | .pg.sql |
myFunc.pg.sql |
| MySQL | .my.sql |
myFunc.my.sql |
| BigQuery | .bq.sql |
myFunc.bq.sql |
| Snowflake | .sf.sql |
myFunc.sf.sql |
| MS SQL | .ms.sql |
myFunc.ms.sql |
| GraphQL | .gql |
myFunc.gql |
| PHP | .php |
myFunc.php |
| Rust | .rs |
myFunc.rs |
| C# | .cs |
myFunc.cs |
| Java | .java |
myFunc.java |
After creating or editing a backend runnable — especially when its imports or arguments changed — its local lock and wmill-lock.yaml go stale. Offer to run wmill generate-metadata and run it once the user agrees (or automatically if the project's AGENTS.md opts into that) — YOU run it, don't just name it and wait. It writes local files only (not a deploy), and keeping the lock current avoids noise in git-sync/CI:
wmill generate-metadata
After it runs, check the regenerated .lock diff and tell the user which dependency versions changed (e.g. requests 2.31.0 → 2.32.0), so they can catch an unwanted bump before deploying.
Optional YAML configuration
Add a <id>.yaml file alongside the code to configure fields or static values:
backend/get_user.yaml:
type: inline
fields:
user_id:
type: static
value: "default_user"
Referencing existing scripts
To use an existing Windmill script instead of inline code:
backend/existing_script.yaml:
type: script
path: f/my_folder/existing_script
For flows:
type: flow
path: f/my_folder/my_flow
Data tables — raw_app.yaml config
The data block in raw_app.yaml controls which tables the app can query.
data:
datatable: main # Default datatable
schema: app_schema # Default schema (optional)
tables:
- main/users # Table in public schema
- main/app_schema:items # Table in specific schema
Table reference formats:
<datatable>— All tables in the datatable<datatable>/<table>— Specific table in public schema<datatable>/<schema>:<table>— Table in specific schema
SQL Migrations (sql_to_apply/)
The sql_to_apply/ folder is for creating/modifying database tables during development.
Workflow
- Create
.sqlfiles insql_to_apply/ - Run
wmill app dev— the dev server watches this folder - When SQL files change, a modal appears in the browser to confirm execution
- After creating tables, add them to
data.tablesinraw_app.yaml
Example migration
sql_to_apply/001_create_users.sql:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
After applying, add to raw_app.yaml:
data:
tables:
- main/users
Migration best practices
- Use idempotent SQL:
CREATE TABLE IF NOT EXISTS, etc. - Number files:
001_,002_for ordering - Always whitelist tables after creation
- This folder is NOT synced — it's for local development only
CLI Commands
Two commands you run yourself, not the user:
wmill app new— run it with flags, per the "Creating a Raw App" section above.wmill generate-metadata— (re)generates local lock files and refresheswmill-lock.yamlcontent hashes; writes local files only (not a deploy). After adding or editing a runnable, offer it and run it on agreement — or automatically if the project'sAGENTS.mdopts into that (see "After creating a runnable" above).
For the rest, tell the user which command fits their intent and let them run it — these deploy to the workspace, overwrite local files, or launch a long-running server, so the user should consent each time:
| Command | Description |
|---|---|
wmill app dev |
Start dev server with live reload (see the preview skill for the full open-the-app-in-the-IDE-pane procedure). |
wmill app generate-agents |
Refresh AGENTS.md and DATATABLES.md |
wmill sync push |
Deploy app to Windmill |
wmill sync pull |
Pull latest from Windmill |
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.
Version History
- 574775d Current 2026-08-20 17:32


