Building Generators

Signals Reference

Before any generator runs, xo scans the project and builds a flat key-value signal map. Signals describe what the project looks like — which files exist, which packages are installed, which framework is detected. Generators use signals to enforce compatibility and adapt their behavior.

How signal scanning works

xo's signal scanner is framework-agnostic. It scans well-known files and derives higher-level signals from them. Framework knowledge lives in generators through their detects rules — the engine stays dumb.

A typical Next.js + TypeScript project signal map:

signal map (computed at runtime)json
{
  "file:package.json": true,
  "file:tsconfig.json": true,
  "file:tailwind.config.ts": true,
  "file:prisma/schema.prisma": false,
  "pkg:react": true,
  "pkg:next": true,
  "pkg:typescript": true,
  "script:build": true,
  "script:dev": true,
  "framework": "nextjs",
  "packageManager": "pnpm",
  "language": "typescript",
  "isMonorepo": false
}

Signal categories

file: prefix

true or false depending on whether the file exists in the project root. Checked files include: package.json, tsconfig.json, pnpm-workspace.yaml, turbo.json, tailwind.config.ts/js, next.config.ts/js, vite.config.ts/js, vitest.config.ts, jest.config.ts/js, Dockerfile, docker-compose.yml/yaml, .env, .env.example, prisma/schema.prisma, prisma.config.ts, drizzle.config.ts, and ESLint / Prettier config files.

pkg: prefix

true for every package in dependencies, devDependencies, and peerDependencies.

{ "signal": "pkg:@nestjs/core", "exists": true }   // NestJS project
{ "signal": "pkg:drizzle-orm",  "exists": false }  // NOT using Drizzle

script: prefix

true for every script name defined in package.json scripts.

Derived signals

SignalTypePossible values
frameworkstring | undefinednextjs, nuxt, sveltekit, react, vue, svelte, nestjs, express, fastify
packageManagerstringpnpm, bun, yarn, npm
languagestringtypescript, javascript
isMonorepobooleantrue if pnpm-workspace.yaml or package.json workspaces present

Using signals in detects

The detects array lets you declare which project types a generator is compatible with. All rules must pass — they are ANDed together.

{
  "name": "acme/prisma-setup",
  "type": "feature",
  "detects": [
    { "signal": "language",                  "equals": "typescript"    },
    { "signal": "packageManager",            "matches": "^(npm|pnpm)$" },
    { "signal": "file:prisma/schema.prisma", "exists":  false          }
  ],
  "actions": [...]
}
FieldTypeMeaning
signalstringThe signal key to check (e.g. pkg:react, framework)
existsbooleanSignal must be truthy (true) or absent/falsy (false)
equalsstringSignal value must exactly equal this string
matchesstringSignal value must match this regex pattern

Generator blocked if detects fails

If any detect rule fails, xo refuses to run the generator with a clear error — it does not silently skip it.

Using signals in prompts and actions

Signals are merged into the context alongside prompt answers, so they're available in when (prompt conditions) and if (action conditions).

In a prompt when field:

{
  "name": "addDockerCompose",
  "type": "confirm",
  "message": "Add docker-compose.yml?",
  "when": "isMonorepo === false && framework === 'nestjs'"
}

In an action if field:

{
  "type": "template",
  "source": "templates/jest.config.ts.hbs",
  "target": "jest.config.ts",
  "if": "language === 'typescript'"
}

Package signals in expressions

In if / when expressions, keys like pkg:react are not valid JS identifiers. Use { signal: 'pkg:react', exists: true } in detects for package checks. For prompt/action conditions, use answers or derived signals like framework and language instead.

Using signals in templates

Signals are also available as Handlebars variables inside template files, letting content adapt to the project automatically.

templates/xo.config.ts.hbshandlebars
// adapts to project setup automatically
export default {
  {{#if (eq packageManager "pnpm")}}
  packageManager: "pnpm",
  {{/if}}
  framework: "{{framework}}",
  language: "{{language}}",
};