Building Generators
Creating a Generator
A generator is a directory with a workflow.yaml entry point plus any templates, actions, and scripts it references. This page walks through everything from a minimal example to a production-ready multi-job generator.
Folder structure
xo-button/
├── workflow.yaml ← entry point (or workflows/add.yaml, workflows/create.yaml)
├── templates/
│ └── button.tsx ← files copied / rendered into the user's project
├── actions/
│ ├── add-barrel.yaml ← composite action (reusable steps)
│ └── validate-name.js ← script action (custom Node.js logic)
└── scripts/
└── post-install.shUse workflow.yaml at the root for a single trigger. Use workflows/add.yaml + workflows/create.yaml for multiple triggers from the same repo:
xo-stripe/
├── workflows/
│ ├── add.yaml ← xo add @github/my-org/xo-stripe
│ └── create.yaml ← xo create @github/my-org/xo-stripe
└── templates/
└── stripe-route.tsMinimal example
The smallest valid generator — one input, one job, one step:
# workflow.yaml
name: ui/button
on: [add]
description: Add a reusable Button component
inputs:
componentName:
prompt: "Component name?"
default: Button
jobs:
scaffold:
steps:
- uses: xo/copy
with:
from: templates/button.tsx
to: "src/components/{{ inputs.componentName }}.tsx"with: values, use {{ inputs.name }} for prompt answers, {{ steps.id.outputs.key }} for step outputs, and Handlebars helpers like pascalCase, kebabCase, includes.workflow.yaml reference
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Unique identifier — conventionally owner/repo |
| on | trigger[] | Yes | Which xo commands trigger this: "add", "create", "run" |
| description | string | No | Short description shown in the CLI |
| detects | DetectRule[] | No | File/package checks that must pass before running |
| requires | string[] | No | Generator names that must already be applied |
| conflicts | string[] | No | Generator names that must NOT be present |
| provides | string[] | No | Logical capability tags this generator declares |
| inputs | Record<string, Input> | No | Interactive prompts collected before jobs run |
| jobs | Record<string, Job> | Yes | Named jobs containing ordered steps |
Defining inputs
Inputs are collected interactively before any jobs run. Answers are available in templates and step with: values as {{ inputs.name }}.
inputs:
appName:
prompt: "App display name?"
type: text
required: true
framework:
prompt: "Framework?"
type: select
choices:
- { value: react, label: "React" }
- { value: vue, label: "Vue" }
default: react
features:
prompt: "Features to include?"
type: multiselect
choices:
- { value: auth, label: "Auth" }
- { value: storage, label: "Storage" }
confirm:
prompt: "Proceed?"
type: confirm
default: true| Type | Renders as | Extra fields |
|---|---|---|
| text | Free-text field | pattern, min, max, required |
| confirm | Yes / No question | default |
| select | Single-choice list | choices, default |
| multiselect | Multi-choice checkboxes | choices |
Input validation
Text inputs support pattern, min, and max. Validation runs inline — a clear error is shown and the prompt is re-displayed on failure.
inputs:
projectName:
prompt: "Project name?"
type: text
required: true
pattern: "^[a-z][a-z0-9_]*$" # regex — must start with a lowercase letter
min: 2 # minimum character length
max: 50 # maximum character length
version:
prompt: "Version?"
type: text
pattern: "^\d+\.\d+\.\d+$" # semver — e.g. 1.0.0| Field | Type | Description |
|---|---|---|
| pattern | string | ECMAScript regex the value must satisfy |
| min | number | Minimum character length |
| max | number | Maximum character length |
Jobs and steps
Jobs run in topological order based on needs: dependencies. Each job contains an ordered list of steps. Use if: on any step to conditionally skip it.
jobs:
detect:
steps:
- uses: xo/detect-pm
id: pm
parallel: true
- uses: xo/pkg-installed
id: hasNext
parallel: true
with:
pkg: next
install:
needs: [detect]
steps:
- uses: xo/install-pkg
with:
pkg: stripe
configure:
needs: [install]
steps:
- if: "steps.hasNext.outputs.installed == true"
uses: xo/copy
with:
from: templates/next-route.ts
to: app/api/stripe/route.ts
- run: "{{ steps.pm.outputs.value }} db:push"Parallel steps
Add parallel: true to steps that can run concurrently. Consecutive parallel steps are grouped into a batch and executed via Promise.all. The batch completes before the next non-parallel step begins.
steps:
- uses: xo/detect-pm
id: pm
parallel: true
- uses: xo/detect-lang
id: lang
parallel: true
- uses: xo/file-exists # waits for both above to finish
id: hasPrisma
with:
path: prisma/schema.prismaHandlebars templates
Template files (used with xo/template) support the full Handlebars syntax. Variables come from inputs, step outputs, and the project config.
# templates/app.tsx
import { Provider } from '{{ inputs.stateManagement === "riverpod" ? "flutter_riverpod" : "flutter_bloc" }}';
// Handlebars helpers work in template content:
// {{ pascalCase inputs.appName }}
// {{ kebabCase inputs.projectName }}
// {{#if (includes inputs.features "auth")}} ... {{/if}}| Helper | Input | Output |
|---|---|---|
| pascalCase | my-component | MyComponent |
| camelCase | my-component | myComponent |
| kebabCase | MyComponent | my-component |
| snakeCase | MyComponent | my_component |
| capitalize | hello world | Hello world |
| eq / ne | {{#if (eq a b)}} | boolean equal / not-equal |
| or / and | {{#if (or a b)}} | logical or / and |
| includes | {{#if (includes arr val)}} | array includes check (multiselect) |
Pre-flight detect rules
Declare detects to verify compatibility before inputs are collected or any steps run. Multiple rules are AND-ed. Supported assertions: exists, equals, matches.
detects:
- file: package.json
exists: true
- pkg: next
exists: trueFull working example
A complete multi-job generator with inputs, detect rules, parallel steps, dependencies, and conditional steps:
# workflow.yaml
name: payment/stripe
on: [add]
description: Add Stripe payment processing
detects:
- file: package.json
exists: true
dependencies:
- auth/jwt
conflicts:
- payment/paddle
provides:
- payment
inputs:
secretKey:
prompt: "Stripe secret key?"
required: true
pattern: "^sk_(test|live)_[A-Za-z0-9]+$"
jobs:
detect:
steps:
- uses: xo/detect-pm
id: pm
parallel: true
- uses: xo/pkg-installed
id: hasNext
parallel: true
with:
pkg: next
install:
needs: [detect]
steps:
- uses: xo/install-pkg
with:
pkg: stripe
- uses: xo/env
with:
file: .env.example
variables:
STRIPE_SECRET_KEY: ""
STRIPE_WEBHOOK_SECRET: ""
configure:
needs: [install]
steps:
- if: "steps.hasNext.outputs.installed == true"
uses: xo/copy
with:
from: templates/stripe-route.ts
to: app/api/stripe/route.ts
- uses: xo/ast-import
with:
file: src/app.module.ts
import: StripeModule
from: ./stripe/stripe.module
- run: "{{ steps.pm.outputs.value }} db:push"Testing locally
Use xo link inside your generator repo to register it by name. From any project, run it with the short name — no path, no --local flag needed. Changes to workflow.yaml or templates are picked up immediately.
$cd ~/projects/xo-stripe$xo link
$cd ~/my-app$xo add payment/stripe$xo add payment/stripe --dry-run -i secretKey=sk_test_abc
For a one-off run without linking:
$xo add ./xo-stripe --local
Publishing to GitHub
Push to any public GitHub repo — no registry account required. Users reference it with the @github/ prefix, or you can register a short name with xo registry add.
$git init && git add .$git commit -m "feat: stripe generator"$git remote add origin git@github.com:my-org/xo-stripe.git$git push -u origin main
# Direct — no registration needed
xo add @github/my-org/xo-stripe
# Pin to a tag (cached forever)
xo add @github/my-org/xo-stripe@v1.0.0
# Subpath in a multi-generator repo
xo add @github/my-org/xo-generators/stripeXO_GITHUB_TOKEN=ghp_xxx in their environment. Branch refs are always re-fetched; tag refs are cached forever.