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 button or a stage trigger.

  • The trigger is the on field: click (default — renders a toolbar button), enter, exit, or hover.
  • 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.
  • disabled: (ctx) => boolean greys out a click button 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.

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.