windo/Guides/For AI agents
Guides~5 min

For AI agents

This page is the rulebook an LLM should read before generating windo code. The conventions here are stable, named, and machine-checkable. The lower half is llms.txt, served at /llms.txt, which compresses the same surface into a single fetchable file.

The shape of a windo project

Every windo project is the same three moving parts. An assistant generating windo code should produce all three, named exactly like this — a reviewer should find each without grep.

  • windo.config.ts — calls defineWindoConfig, declares groups (sidebar sections), tags (cross-cutting filter labels), and contexts (ambient controls + providers), and re-exports the windo factory it hands back. One per project.
  • *.windo.tsx — one file per component on the canvas. Default-exports windo(w => definition). The group field — and any tags — are type-checked against the config.
  • Wiring — either the Vite plugin (windo({ config }) from @westopp/windo/plugin) in a vite.config.ts, or the bundled windo dev CLI. Never both for the same root.

There is no fourth pattern. windo runs as a dev server (windo dev) and also builds to a static windo-static/ you can host (windo build, then windo preview to check it).

The one rule that breaks most generations

The factory passed to windo(w => …) runs once at definition time. Its body is not a render. Static fields (title, group, tags, configurableProps, placement, variants, props, status, description) are read then. Function fields — component, defaultProps, providers, code, and each action's run / disabled — run later, at render time, with the live ctx.

So ctx only exists inside those functions. Never read ctx.colorScheme, ctx.state, or a context value in the factory body — there is no ctx there yet. Branch on the environment inside component, not around the returned object.

TSXbad-vs-good.windo.tsx
// BAD — reads ctx in the factory body (runs once, no ctx exists)
export default windo<BadgeProps>((w) => {
const scheme = ctx.colorScheme            // ✗ ctx is not defined here
return { title: 'Badge', group: w.groups.feedback.slug, component: (p) => <Badge {...p} theme={scheme} /> }
})

// GOOD — reads ctx at render time, inside component
export default windo<BadgeProps>((w) => ({
title: 'Badge',
group: w.groups.feedback.slug,
component: (props, ctx) => <Badge {...props} theme={ctx.colorScheme} />,
}))

Configurable props are zod, and they merge onto defaultProps

The Controls editor speaks JSON. configurableProps<Props>()(schema) is the validator + parser for that JSON; defaultProps holds the full props, including the non-serialisable ones (functions, JSX, refs) JSON can't express.

  • configurableProps<Props>() is an identity helper — it constrains the schema's output to Partial<Props> and returns the schema unchanged.
  • On every edit, the iframe parses the JSON with the live schema. Valid z.output merges on top of defaultProps; whatever the JSON omits falls back to the default.
  • Anything that isn't serialisable lives only in defaultProps and in variants — never in the schema.
  • z.input is the edit surface (what you type); z.output is what the component receives (after coerce / refine / default).
TSXbutton.windo.tsx
import { z } from 'zod'
import { configurableProps } from '@westopp/windo'
import { windo } from './windo.config'
import { Button, type ButtonProps } from './components/Button'

const schema = configurableProps<ButtonProps>()(
z.object({
  children: z.string().default('Create project'),
  variant: z.enum(['primary', 'secondary', 'outline', 'ghost', 'destructive']).default('primary'),
  size: z.enum(['sm', 'md', 'lg']).default('md'),
  loading: z.boolean().default(false),
  disabled: z.boolean().default(false),
}),
)

export default windo<ButtonProps>((w) => ({
title: 'Button',
group: w.groups.primitives.slug,
status: 'stable',
configurableProps: schema,
// Full props live here — onClick is a function, so it can't come from JSON.
defaultProps: { variant: 'primary', size: 'md', loading: false, disabled: false, children: 'Create project', onClick: () => {} },
component: (props, ctx) => <Button {...props} onClick={(e) => ctx.logger.log('click', e)} />,
}))

State and actions, not hooks

A windo can't use React hooks for its own demo state — the factory isn't a component. Component-local state is declared with the second generic, seeded with state, read via ctx.state, and written via ctx.setState(patch) (a shallow merge). actions drive it from outside the component: a toolbar control or a stage trigger.

  • The kind is the type field: button (default — renders a toolbar button), select (a toolbar dropdown), enter, exit, or hover.
  • For button and the pointer kinds, run: (ctx, active) => void receives the live ctx and an active flag. For hover, active is true on enter and false on leave; for the others it's always true.
  • For select, declare options (a string or { label, value }) and run: (ctx, value) => void receives the picked value. It's a pure trigger — the control snaps back to its label, never tracking a current value.
  • disabled: (ctx) => boolean greys out a toolbar control (button or select) against live state.
TSXtoast.windo.tsx
export default windo<ToastProps, { open: boolean }>((w) => ({
title: 'Toast',
group: w.groups.feedback.slug,
state: { open: false },
actions: [
  { label: 'Show', run: (ctx) => ctx.setState({ open: true }) },
  { label: 'Hide', run: (ctx) => ctx.setState({ open: false }), disabled: (ctx) => !ctx.state.open },
],
defaultProps: { message: 'Saved your changes' },
component: (props, ctx) => <Toast {...props} open={ctx.state.open} />,
}))

Variants are diffs, not snapshots

A variant is { label, props } where props is a Partial<Props> patch overlaid on defaultProps. Only write the keys that differ from the defaults. Because variants are static data (not JSON), they may carry non-serialisable props — JSX, icons, functions — that the Controls editor can't reach. Setting a key to undefined in a variant clears the default for that slot.

TSXbadge.windo.tsx
variants: [
{ label: 'neutral', props: { variant: 'neutral', children: 'Draft' } },
{ label: 'success · dot', props: { variant: 'success', dot: true, children: 'Active' } },
{ label: 'with icon', props: { iconLeft: <Icon name="plus" />, children: 'New' } },  // JSX — only a variant can do this
]

Contexts: registering makes values ambient; uses mounts the provider

This is the distinction that trips generators up. A context (defineContext) can contribute controls (ambient values + chrome toggles), a provider (a React wrapper), or both.

  • Registering a context in config.contexts makes its values ambient — every windo can read them off ctx.contexts[name], no opt-in.
  • uses: ['name'] on a windo mounts that context's provider around it. Mounting is opt-in; reading values is not.
  • A context with only controls is ambient-only (no uses needed). For a wrapper that belongs to exactly one windo, use the per-windo providers field instead of a shared context.

Placement anchors the component in the frame

placement decides where the component sits in the resizable canvas frame. It defaults to center. Ten base anchors (center, fill, top, bottom, left, right, four corners), each available flush or with a -padding suffix that insets it from the edges — twenty values total, the union WindoPlacement. Pick flush when edge contact is the point (a sticky header is top); pick -padding for components that normally live with margin.

Tags are free-form, type-checked labels

Where group answers where a component lives (one section), tags answer what it's for — a component carries any number. Declare the vocabulary once in config.tags; a windo's tags are then checked against that list, exactly like group. They drive the sidebar's multi-select filter (match any or all). An undeclared tag is a compile error; omit tags from the config entirely and the filter never appears.

TStags.ts
// windo.config.ts — declare the vocabulary
defineWindoConfig({
groups: [/* … */],
tags: ['web-app', 'admin-panel', 'marketing'],
})

// badge.windo.tsx — apply a subset, type-checked against the declared tags
windo(() => ({ title: 'Badge', group: 'data-display', tags: ['web-app', 'admin-panel'], /* … */ }))

Other invariants worth knowing

  • One import surface per side. Authoring API (defineWindoConfig, defineContext, configurableProps, describeSchema, the WINDO_* constants, and all types) comes from @westopp/windo — it's isomorphic, no DOM or Node code. The plugin comes from @westopp/windo/plugin. Don't import client/preview entry points by hand; the plugin wires them.
  • group must be a configured slug. Reference it as w.groups.<slug>.slug so TypeScript checks it. A literal string that isn't a registered slug is an error.
  • windo is a dev dependency, installed with zod from the public npm registry: npm i -D @westopp/windo zod. No scope or registry configuration needed.
  • Three commands. windo dev (dev server), windo build (static windo-static/ for any host — index.html + __windo/iframe.html, relative base by default; named windo-static so it never clobbers a project's own dist/), windo preview (serve a build locally). Under vite build the plugin emits those two HTML entries as real inputs.
  • describeSchema(schema) flattens a schema's input shape into a serialisable WindoSchemaDescriptor — that's how the schema crosses the chrome↔iframe boundary. The zod object itself never crosses it.
  • South African English in code, comments, and prose (behaviour, serialise, colour, cancelled). External identifiers (onChange, localStorage) keep their original spelling.

Workbench chrome invariants

Facts about the running workbench an agent driving or describing it should hold:

  • Fullscreen is button-only. The canvas toolbar's fullscreen button is the sole way in and out — Esc does not exit (it is reserved for the settings popover and the narrow-screen drawers). Fullscreen hides the topbar, sidebar, and inspector.
  • Handles hidden means full-bleed. In fullscreen the resize handles are hidden by default and the frame fills the stage edge to edge; the persisted width/height are ignored for render but never overwritten, and they restore on exit. A session-only settings-popover row toggles the handles back.
  • Two unrelated breakpoint systems. The chrome reflows at 860px browser width (panels become drawers, presets a select, actions an overflow menu). The preview's viewport namesmobile < 640, tablet < 1024, desktop above — describe the frame width and reach components as ctx.viewport.name. Resizing the browser moves the first; resizing the frame moves the second.
  • Chrome state persists under windo:* localStorage keys — theme, selection, tag filters, frame size, zoom, grid, env, shared state, pane collapse. Fullscreen and its toggles are deliberately session-only.

llms.txt — the file in full

The raw llms.txt as it's served from this site. Hit copy to paste it into your AI tool's project rules or a vendored file.

llms.txtserved at /llms.txt
Loading /llms.txt…

Where it lives

The canonical file is served at /llms.txt from this site. It follows the llmstxt.org convention — a single Markdown file with a one-paragraph summary, the project's load-bearing invariants, and grouped links into the deeper docs.

fetch
curl https://windo.westopp.com/llms.txt

Pointing an AI tool at it

Most editor-side AI tools accept either a URL or a local file as project-scoped context. Two patterns work:

rules / prompt snippet
# windo context
When working with @westopp/windo, treat https://windo.westopp.com/llms.txt
as authoritative. Fetch it before answering questions about the API surface or
generating a *.windo.tsx file. Follow links in it for deeper detail.
vendor it
# If your tool prefers a local file, drop a copy into your repo:
mkdir -p .ai && curl -fsSL https://windo.westopp.com/llms.txt -o .ai/windo.llms.txt

What to expect (and what not to)

The file is hand-maintained, not generated. It tracks the public surface exported from @westopp/windo (and @westopp/windo/plugin) and the published docs pages on this site — internal modules (the chrome, the preview runtime, the postMessage bridge) aren't listed, by design.

If your assistant invents a field or an export that isn't in llms.txt and isn't on the linked pages, treat that as a hallucination. The definition surface is closed: if a field isn't on WindoDefinition and an export isn't from @westopp/windo, it isn't part of the contract.