Authoring

Build an npm package that adds components, templates, themes, docs, and codemods to Astryx apps, and look up every field of every file it holds.

Overview

In global mode: users can install your integration for their computer, beside the global CLI. It then applies in folders outside any project, and inside a project only if the project installs it. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

An integration is a way to share things with Astryx users: a package you own and maintain, built on a framework Astryx gives you. Publish one, many, or any mix, and people install it in one step. It works with the Astryx CLI alongside Core. See each kind under npx @astryxdesign/cli docs authoring/building-blocks.

For example, ship a carousel as an integration. Any app that installs it can find it with npx @astryxdesign/cli search carousel, and the CLI uses it like any Astryx Core component.

An integration can also replace a built-in template or doc. To start, install @astryxdesign/cli and open npx @astryxdesign/cli docs authoring/quick-start.

Every field of every file you write for an integration is in npx @astryxdesign/cli docs authoring/field-reference.

Every integration runs npx @astryxdesign/cli integration check --strict in its own build: it is required, not optional, so a doc, prop, or link that stops matching the code fails your build instead of your users' apps. See npx @astryxdesign/cli docs authoring/ship/checks.

Quick start

Make the package

An integration starts as a normal npm package. Make a folder for it, create a package.json inside that folder, give the package a name, make it an ES module, then install the Astryx CLI, Core, and TypeScript for development.

bash
mkdir acme-widgets && cd acme-widgets
npm init -y
npm pkg set name=@acme/astryx-widgets type=module
npm pkg set 'exports={}' --json
npm install -D @astryxdesign/cli @astryxdesign/core typescript@6
  • Set "type": "module": the .js files the CLI generates, such as a built theme, are ES modules, and Node fails to load them in a package marked "type": "commonjs", which npm init -y writes on npm 11.
  • Start with "exports": {}: each component and template you add then writes the public import that integration verify resolves.
  • Run the CLI as npx @astryxdesign/cli, which runs the @astryxdesign/cli you installed as a devDependency.
  • Component commands read Core, so they need @astryxdesign/core installed.
  • The checks compare your docs with your code through TypeScript's JavaScript compiler API, which typescript 5.0 through 6 has. TypeScript 7 does not ship that API: on TypeScript 7, also install @typescript/typescript6, its TypeScript 6 API package, and the checks use it.

Add your first integration item

An integration can ship several kinds of items. Start with a component named AcmeCarousel.

bash
npx @astryxdesign/cli integration add component AcmeCarousel
text
component contribution added
​
[ok] AcmeCarousel
​
Declare component root ./components in astryx.integration.mjs.
​
- components/AcmeCarousel.doc.mjs
- components/AcmeCarousel.tsx
- package.json
- astryx.integration.mjs

integration add creates the component source, its .doc.mjs, the package export, and astryx.integration.mjs. Update the generated doc so the CLI understands the component, then write the component itself.

Read it back the way an app will.

bash
npx @astryxdesign/cli component AcmeCarousel

You now have an integration package with one exported component.

Check it in every build (required)

Run integration check --strict in every build (npx @astryxdesign/cli docs authoring/ship/checks): a doc, prop, template, or link that drifts from the code then fails your build, not your users' apps. Set it up now, as the typecheck script, and run it.

bash
npm pkg set scripts.typecheck="astryx integration check --strict"
npm run typecheck
text
[ok] 0 errors, 0 warnings, 0 skipped. Every check passed.
  • Run typecheck in CI and before every publish. When you add a tsconfig.json, put tsc --noEmit && in front of the check so the same script checks your types.
  • What each check covers, and what --strict adds: npx @astryxdesign/cli docs authoring/ship/checks.

Verify and pack the package

Run integration verify to check that an app would see the component, then make the .tgz file you install next. --pack-destination .. writes it beside the package folder, so the next pack does not ship it.

bash
npx @astryxdesign/cli integration verify
npm pack --pack-destination ..
text
Integration package ready
​
[ok] @acme/astryx-widgets@1.0.0
​
4 packed files; 3/3 required files present.

integration verify installs the packed package in a temporary app and confirms the component resolves through its public import there (npx @astryxdesign/cli docs authoring/ship/checks). It publishes nothing and leaves no .tgz file, so npm pack writes ../acme-astryx-widgets-1.0.0.tgz for the next step.

Use it in an app

Install Core, the CLI, and your .tgz file in a new app. The app loads your package because it is a dependency, with no config.

bash
cd ..
mkdir my-app && cd my-app
npm init -y
npm install @astryxdesign/core @astryxdesign/cli ../acme-astryx-widgets-1.0.0.tgz
npx @astryxdesign/cli component AcmeCarousel
text
**Import:** `import {AcmeCarousel} from '@acme/astryx-widgets/components/AcmeCarousel';`

The next sections explain each package field and the integration file. To build the real component, continue with npx @astryxdesign/cli docs authoring/building-blocks/components.

Fill in package.json

An integration is an ordinary npm package. These six fields control how it is named, developed, checked, and published.

peerDependencies are packages the app supplies. devDependencies are packages you use while building the integration.

FieldSet it toWhy
nameYour package name, such as @acme/astryx-widgetsEach add writes it into the import of the doc it generates. Rename before you add, or update each import after.
versionThe release you publish, such as 1.0.0Apps see it; astryx.integration.mjs never repeats it.
exportsStart with {}Each component and template add writes its public import here, and integration verify resolves it.
filesOptional: the paths to publishKeeps private files out. When the list exists, each add appends its root and astryx.integration.mjs.
peerDependencies@astryxdesign/core when your code imports it; @astryxdesign/cli, optional, when you ship a feature that needs a newer CLI (npx @astryxdesign/cli docs authoring/ship/versioning)The app supplies these packages for your integration.
devDependencies@astryxdesign/cli, @astryxdesign/core, and typescriptLets you run the CLI, build components, and run the checks while working on the integration.

Peer ranges and releases are covered in npx @astryxdesign/cli docs authoring/ship/versioning; files and publishing in npx @astryxdesign/cli docs authoring/ship/publishing. Add "typecheck": "tsc --noEmit && astryx integration check --strict" to scripts so each run checks your types and that your docs, props, and links still match the code. It needs typescript and a tsconfig.json; see npx @astryxdesign/cli docs authoring/ship/checks.

The integration file

The first integration add creates astryx.integration.mjs, and each later add updates it. You do not need to write or edit this file during the quick start.

Files in the package

  • package.json defines the npm package, including its name, version, dependencies, published files, and exports.
  • astryx.integration.mjs tells the CLI where the package keeps each kind of integration item.
  • The integration item files live under those roots. For example, a component can have AcmeWidget.tsx for its source and AcmeWidget.doc.mjs for its documentation.

Fields in the integration file

astryx.integration.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').AstryxIntegration} */
export default {
components: './components',
};

The file tells the CLI where this package keeps its integration items. Edit it only when you want a custom root or another optional setting. Paths are relative to package.json, and later adds keep a custom path you already set.

Every field is in npx @astryxdesign/cli docs authoring integration.

Use the CLI

You can edit the package files by hand, but the CLI handles the normal workflow. These links always open the canonical command docs.

astryx integration add: Add one working contribution to an integration package. Read it with npx @astryxdesign/cli docs cli/commands/integration-add.

astryx integration verify: Check the package the way npm will publish it, before you publish. Read it with npx @astryxdesign/cli docs cli/commands/integration-verify.

astryx doctor integration: Check an integration package while authoring it. Read it with npx @astryxdesign/cli docs cli/commands/doctor-integration.

In global mode

In global mode: users can also install your package for their computer, beside the global CLI (npm i -g @astryxdesign/cli your-package). It then applies in folders outside any project, and inside a project only if the project installs it. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Building Blocks

In global mode: every kind your package adds also applies outside projects when users install it for their computer, beside the global CLI; inside a project, only if the project installs it. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

These are everything you can add to an integration. Mix them however you like. You are not limited to any one thing.

  • One component, or a whole library of them.
  • Components together with themes and templates.
  • Only docs, if that is all you need.
  • Agent guidance, so the AI agents in an app follow your library.
  • Codemods that update app code across your breaking changes.

Each kind below is its own guide. Open one to learn, in depth, how to add it and how to keep it healthy as your package grows.

Components

In global mode: components from an integration installed for the computer show up outside projects; inside a project, only components from integrations the project installs. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Pick a component name

Choose a unique PascalCase name, such as AcmeCarousel. The name is the component's public export and CLI identity, so keep it stable. Use displayName when you only want to change the label people read.

Run the add command

Run integration add component in your package with the name. It writes the component, its doc file, and the public import apps use.

bash
npx @astryxdesign/cli integration add component AcmeCarousel
text
component contribution added
​
[ok] AcmeCarousel
​
Declare component root ./components in astryx.integration.mjs.
​
- components/AcmeCarousel.doc.mjs
- components/AcmeCarousel.tsx
- package.json
- astryx.integration.mjs

integration add starts with one source export and one single-component .doc.mjs. Keep that shape for one public component. If the source exposes a family, choose the correct shape in npx @astryxdesign/cli docs authoring/building-blocks/components/describe-the-component.

Add never overwrites a file, and --dry-run shows what it would write. Component commands read Core, so install @astryxdesign/core in the package first (npx @astryxdesign/cli docs authoring/quick-start).

Document a component you already have

To document a component your package already has, write its doc beside the source file and name the doc after that file, such as acme-gallery.doc.mjs beside acme-gallery.tsx. Set name to the export apps import, such as name: 'AcmeGallery', and keep both files inside the components root that astryx.integration.mjs declares. The file does not have to be named after the component; npx @astryxdesign/cli docs authoring/building-blocks/components/component-imports shows how apps then find it.

In global mode

In global mode: when users install your package for their computer, beside the global CLI, your components show up in folders outside any project; inside a project, only if the project installs your package. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Describe the component

A component's .doc.mjs is part of the integration's public contract, not optional commentary. Astryx uses it for CLI output and search, and people and agents read it to decide whether the component fits and how to use it.

Every component doc has one stable identity and enough usage guidance for a reader to choose it correctly. Pick the shape below that matches your module.

After every source or doc change, read the component back. This output is what people and agents receive.

bash
npx @astryxdesign/cli component AcmeCarousel

Start from the generated doc

integration add component creates the normal doc for one public component. Keep the generated identity and import, then replace its sample text and props with the component's real public contract.

components/AcmeCarousel.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
export default {
type: 'component',
name: 'AcmeCarousel',
displayName: 'Acme Carousel',
import: '@acme/astryx-widgets/components/AcmeCarousel',
usage: {
description:
'Cycles through slides one at a time. Use it for a small set of related cards.',
},
props: [
{
name: 'slides',
type: 'ReactNode[]',
description: 'The slides to show, in order.',
required: true,
},
],
};

Every field is in npx @astryxdesign/cli docs authoring component-doc.

Explain when to use it

Write usage.description so a person or agent can decide whether this is the right component without opening its source. Say what it does, when to use it, and the most important boundary with a nearby alternative.

javascript
usage: {
description:
'Cycles through slides one at a time. Use it for a small set of related cards. Use a static list when every item should stay visible.',
bestPractices: [
{guidance: true, description: 'Keep the slide order stable while someone interacts with the carousel.'},
{guidance: false, description: 'Hide information that must remain visible for comparison.'},
],
},

Set keywords to the words people search for: synonyms and the task the component does, such as keywords: ['slider', 'slideshow', 'rotating cards']. npx @astryxdesign/cli search ranks a match on a component's name or keywords above one in its description, so without keywords a search for the component's task can list Core components first. Check with the words a person would type:

bash
npx @astryxdesign/cli search "rotating cards" --type component

Document every public prop

Copy the public prop names and types from the source. Explain the behavior a caller controls, not only the TypeScript type.

javascript
props: [
{
name: 'slides',
type: 'ReactNode[]',
description: 'The slides to show, in order.',
required: true,
},
{
name: 'interval',
type: 'number',
description: 'Milliseconds between automatic slide changes.',
default: '5000',
},
],

Add focused examples

Add short examples for important usage that the prop table does not make obvious. Each example should teach one complete pattern and use only public imports.

javascript
examples: [
{
label: 'Automatic rotation',
code: '<AcmeCarousel slides={slides} interval={5000} />',
},
],

Read the result

Read the component after every source or doc change. Confirm that its purpose, import, props, defaults, and examples match the source, then verify the packed package.

bash
npx @astryxdesign/cli component AcmeCarousel
npx @astryxdesign/cli doctor integration components
npx @astryxdesign/cli integration verify

Choose the family shape

Start with a complete single-component doc. Convert its top-level props into a components array only when one source module or component directory exposes several public components or hooks as one family. The family doc keeps the shared usage guidance; each array entry owns one public export.

  • Put the primary or most-used export first.
  • Use a full entry when this file owns that export's description and signature.
  • Use props for a component entry. Use params and returns for a hook entry.
  • Do not add private implementation helpers or exports that people should not use directly.

Every field is in npx @astryxdesign/cli docs authoring component-doc.

Document the family inline

components/AcmeTabs.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
export default {
type: 'component',
name: 'AcmeTabs',
displayName: 'Acme Tabs',
import: '@acme/astryx-widgets/components/AcmeTabs',
usage: {
description:
'Switches between related views without leaving the page.',
},
components: [
{
name: 'AcmeTabs',
displayName: 'Acme Tabs',
description: 'Owns selection and lays out the tab list and panels.',
props: [
{
name: 'value',
type: 'string',
description: 'The selected tab value.',
required: true,
},
],
},
{
name: 'AcmeTab',
displayName: 'Acme Tab',
description: 'Selects one view in Acme Tabs.',
props: [
{
name: 'value',
type: 'string',
description: 'The value this tab selects.',
required: true,
},
],
},
],
};

The public module named by import must export every component or hook named by a full entry. Apps then list, search and open each component in the family by its own name. A full entry the module does not export is not listed, and integration verify does not report it: check the family with npx @astryxdesign/cli component --list. When import names output your pack lifecycle builds (such as dist), the members are listed once that output exists, and integration verify compares them as they are listed after the pack builds it.

A member's source is the .tsx file in the family doc's folder, or below it, that declares it: the one the family's own file or the module import names reaches, else the one in that folder that declares the name, as when import names built output. A member that only a bundle or a file outside that folder declares is still listed, with no source for --source or swizzle. A member is not listed when a doc's file or a Core name your package replaces already has its name. A member that shares a name with Core opens with --package.

Give a member its own file

When one family member needs its own doc, replace its full entry with {name: 'MemberName'} and move the details into a sibling doc. The parent keeps the family relationship without copying the child's content. Continue with npx @astryxdesign/cli docs authoring/building-blocks/components/describe-the-component/subcomponent.

Choose a separate doc

Start with a component family doc. Move one public member into a sibling doc when it has its own source and enough behavior, props, or usage guidance to maintain separately. The parent still lists the member, but only by name.

  • Keep a small member inline when its whole contract stays clear in the family doc.
  • Use a sibling doc when the member needs focused search results, examples, usage guidance, or independent maintenance.
  • Give each member one documentation owner. Do not keep a full parent entry and a sibling doc for the same name.

Reference it from the parent

components/AcmeDialog.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
export default {
type: 'component',
name: 'AcmeDialog',
displayName: 'Acme Dialog',
import: '@acme/astryx-widgets/components/AcmeDialog',
usage: {description: 'Presents a focused task above the current page.'},
components: [
{
name: 'AcmeDialog',
displayName: 'Acme Dialog',
description: 'Owns the modal surface and open state.',
props: [],
},
{name: 'AcmeDialogHeader'},
],
};

The name-only entry keeps AcmeDialogHeader in the family. Its description and props come only from the sibling file.

Write the subcomponent doc

components/AcmeDialogHeader.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
export default {
type: 'component',
name: 'AcmeDialogHeader',
displayName: 'Acme Dialog Header',
import: '@acme/astryx-widgets/components/AcmeDialogHeader',
subComponentOf: 'AcmeDialog',
description: 'Labels an Acme Dialog and holds its close action.',
props: [
{
name: 'title',
type: 'string',
description: 'The dialog title.',
required: true,
},
],
};

Every field is in npx @astryxdesign/cli docs authoring component-doc.

  • subComponentOf must exactly match the parent doc's name.
  • description explains this member's role in the family. usage is optional; add it when the member needs guidance beyond that sentence.
  • The CLI does not copy family fields such as group, category, or keywords from the parent to the child. Set any the child needs in its own doc.

Run the props check

doctor integration components compares every props table in your component docs, hidden docs and every family entry included, with the props the component's TypeScript declares. Run it in your package after you change a component or its doc.

bash
npx @astryxdesign/cli doctor integration components
text
severity: [warn]
component: AcmeCarousel
prop: loop
kind: not_in_code
message: "loop" is documented, but AcmeCarousel's props have no "loop".
doc: components/AcmeCarousel.doc.mjs
source: components/AcmeCarousel.tsx
docType: boolean
​
1 prop(s) differ between the docs and the code in 1 of 1 component(s) compared (TypeScript 6.0.3). These are warnings: update the doc, or the code if the doc is right.
  • A doc's own props describe the export its name names. In a family doc, each entry describes its own export. A hook entry documents parameters and returns, so it is not compared.
  • It reads props with the typescript your package installs, and reads your package's tsconfig.json the way tsc -p does. It finds TypeScript the way the docs check does, TypeScript 7 included (npx @astryxdesign/cli docs authoring/building-blocks/docs/check-your-docs). Without a usable one it prints [skip] Props not compared and the reason.
  • Findings are warnings and leave the exit code at 0. Read them before you publish. With --json, they are in data.propDrift.findings.

Fix each finding

Each finding's kind says what differs. Fix the doc, or the code if the doc is right.

KindMeansFix
not_in_codeThe doc lists a prop the component's props do not haveRemove it from the doc, or add it to the code. A name an index signature takes, such as data-*, counts as present
not_in_docYour package declares a prop the doc does not list, or builds one with a mapped type, such as Partial<Record<'sm' | 'md', string>>Document it. Props from React, the DOM, or another package are never asked for, and neither are props whose keys another package defines (keyof CSSProperties, behind an alias or not), className, style, xstyle, data-testid, ref, key, or a prop whose JSDoc has @internal. A deprecated prop is still a prop: document it until you remove it. A prop a mapped type builds from keys the check cannot trace (a template literal, say) is listed under [info] Props not checked for a doc entry instead
required_mismatchrequired disagrees with the codeSet required: true exactly when every caller must pass the prop. One your defaultProps fills counts as optional, as it is to JSX callers, and so does one only some members of a union require, since a caller can leave it out: say in its description which variants need it. An entry that names several props (value / onChange) makes the same claim about each: give them separate entries when they differ
type_mismatchThe documented type differs from the code's, where both read exactlyCopy the type from the source, or fix the code. The next section says which types are judged
default_mismatchThe documented default differs from the literal default the component writes in its props destructuring, such as size = 'md'Copy the default from the source, quoted as code: default: "'md'". Quotes and spacing are ignored, and a default that is not a literal on either side (a constant, a call, prose) is not compared

Write types the check can judge

For a prop your package declares, the check also compares the documented type, but only where both sides read exactly. It never guesses, so a type it cannot judge is listed, not reported. It needs TypeScript 4.8 or later: with an older one it prints [skip] Prop types not judged and judges none.

text
severity: [warn]
component: AcmeCarousel
prop: interval
kind: type_mismatch
message: "interval" is documented as string, but the code declares number.
doc: components/AcmeCarousel.doc.mjs
source: components/AcmeCarousel.tsx
docType: string
codeType: number
  • Write the type as the source writes it, such as ReactNode[] or (index: number) => void. Parameter names, member order, quotes and whitespace may differ. Text that writes readonly must match exactly, parameter names included; only whitespace may differ. React.X names React's own X where the prop is declared, so it matches a bare X only where that name is React's: React.KeyboardEvent is not the DOM's KeyboardEvent.
  • Name a type as the file that declares the prop names it, so an import renamed there (Item as BaseItem) is written as that file writes it. A field (cfg.level) takes its prop's file, even when the field's own type is declared in another file.
  • A type written another way is judged only when both sides are built from exact parts: primitives such as string and number, string, number and boolean literals, string enum members, arrays and unions of these, and type names that stand for them. Union members may come in any order, and a member another covers adds nothing, as TypeScript passes it there: undefined beside void, or string[] beside (string | number)[].
  • A mutable array type matches a readonly one, because callers can pass it. A readonly one documented for a mutable array is a mismatch.
  • When a prop's variants give a field different types, as {value: string} | {value: number} does, document every variant's type (string | number). The types of only some variants are listed: the prop's variants give this field different types. With more than six variant types, any other type is listed too.
  • To name an alias and show its values, write AcmeSize = 'sm' | 'md' or AcmeSize ('sm' | 'md'). Both parts must match.
  • An optional prop's undefined is set aside on both sides. With exactOptionalPropertyTypes, the doc shows undefined exactly when the code declares it.
  • Anything else, such as an object, a tuple, a generic with arguments, keyof, an intersection, or a mapped or conditional type written another way, is not compared.

The check prints how many documented types it judged, then lists the rest by reason: not a form the check judges exactly, a field the prop's variants give different types, a standard prop, or a prop declared outside the package. With --json, they are in data.propDrift.typesNotCompared.

text
[info] Documented prop types judged: 2; not compared: 0. A type is judged when the doc writes it the same as the source (exactly, if it contains `readonly`), or when both are primitives, literals, arrays and unions of those, or names for them.

When a component is not compared

When the check cannot read a component's props exactly, it lists the component under [info] Props not compared, with the reason, instead of reporting findings that might be wrong.

text
[info] Props not compared for 1 documented component(s):
​
component: AcmeCarousel
doc: components/AcmeCarousel.doc.mjs
reason: AcmeCarousel depends on `@acme/motion` (imported in AcmeCarousel.tsx), which the compiler could not resolve, usually a dependency that is not installed. Install the package's dependencies and run this check again
ReasonFix
It depends on a package or file the compiler could not resolveInstall the package's dependencies, then run the check again
It depends on a type the compiler can't work outMake the types compile with npx tsc --noEmit, then run the check again
The package's TypeScript config has an errorFix tsconfig.json until npx tsc -p . reads it
Its props are typed anyGive the component a props type
Some prop names come from a type parameterNothing to fix: a component that renders an element the caller picks takes props known only where it is used
The file has no export named by the doc's nameChange name or the export so they agree

Export the component

Apps copy the component doc's import field into their code, so that exact specifier must resolve from your packed package. integration add writes it together with an exports entry in package.json.

package.json
json
"exports": {
"./components/AcmeCarousel": "./components/AcmeCarousel.tsx"
}

Add writes the entry only when package.json already has an exports map, so start every package with "exports": {}.

A file does not have to be named after its component. When Slides.tsx exports AcmeCarousel and nothing named Slides, keep the doc beside it as Slides.doc.mjs and set its name to AcmeCarousel: apps list, search and open the component by that name, and integration verify checks that its import exports AcmeCarousel. A file that exports its own name keeps that name. A package that names its files in kebab case works the same way: acme-gallery.doc.mjs beside acme-gallery.tsx sets name: 'AcmeGallery'.

The file name still opens the doc. A name that Core or another doc's file already has stays theirs, and so does a Core name your package replaces: with Badge.doc.mjs naming AcmeBadge, npx @astryxdesign/cli component Badge opens Core's Badge, and --package with your package name reaches yours.

Verify the packed import

integration verify unpacks the packed package into a temporary app, resolves each documented import, and checks that the module exports the documented component name. Its --json output names each failure by code.

bash
npx @astryxdesign/cli integration verify
  • component_import_unresolvable means the exports map has no entry for the documented import.
  • component_export_missing means the module does not export the documented name, or package.json has no exports map.
  • typescript_extension_in_specifier means the public import ends in .ts or .tsx.

To import from the package root, set import: '@acme/astryx-widgets' and re-export the component from the file that exports["."] points to. See npx @astryxdesign/cli docs cli/commands/integration-verify.

In global mode

In global mode: outside a project, imports the CLI prints name packages installed for the computer, which a new app must install before it can build them; inside a project, only packages the project installs are printed. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Read the installed component

An app that installs your package sees the component beside Core components, with its package name and public import. The app needs no config.

bash
npx @astryxdesign/cli component AcmeCarousel
npx @astryxdesign/cli component --list
npx @astryxdesign/cli search carousel
text
package: @acme/astryx-widgets
​
# AcmeCarousel
​
Cycles through slides one at a time. Use it for a small set of related cards.
​
**Import:** `import {AcmeCarousel} from '@acme/astryx-widgets/components/AcmeCarousel';`

component --list shows it as import: @acme/astryx-widgets/components/AcmeCarousel [@acme/astryx-widgets]. The same commands work in your package while you build it.

Test the packed package

Install the packed package in a test app before publishing. Then run the same detail, list, and search commands there. See npx @astryxdesign/cli docs authoring/ship/test-in-an-app.

In global mode

In global mode: to check your package in an app, install it in that app; a copy installed for your computer is not used inside a project. To check it outside projects, install it for your computer beside the CLI. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Decide whether to replace

Most components should not use replaces. Give a new component its own name and let apps choose it explicitly. Replace Core only when your component must become the default for one Core identity everywhere the integration is active.

Use it when

  • Your component intentionally serves the same role as one specific Core component.
  • Every app that loads the integration should get your component from unqualified component detail, component --list, search, swizzle <Name>, and issue routing.
  • You have tested both the replacement and explicit access to the original Core component.

Do not use it when

  • Your component is an alternative, variant, wrapper, or product-specific extension. Give it a unique name instead.
  • You only need to resolve an accidental name collision.
  • You want the replacement in only one screen or workflow. Replacement applies across the app wherever the integration is active.

Set the replacement

components/AcmeSideNav.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
export default {
type: 'component',
name: 'AcmeSideNav',
displayName: 'Acme Side Nav',
replaces: 'SideNav',
import: '@acme/astryx-widgets/components/AcmeSideNav',
usage: {description: 'Product navigation for Acme apps.'},
props: [],
};

Every field is in npx @astryxdesign/cli docs authoring component-doc.

The replacement turns on only when your package declares the first CLI release that applies it. Add this to its package.json:

package.json
json
{
"peerDependencies": {"@astryxdesign/cli": ">=0.6.7"},
"peerDependenciesMeta": {"@astryxdesign/cli": {"optional": true}}
}
  • Any @astryxdesign/cli range that starts at that release or later turns it on, including the >=0.7.0 that integration add doc --parent wrote in published 0.6.4 and 0.6.5, and integration add theme in published 0.6.5.
  • Without such a range, the component keeps its own name, the Core component stays selected (a component named like its target stays ambiguous by that bare name, as before), and doctor integration components warns with the range to add. Apps that load such a package see no change.
  • replaces names the Core ComponentDoc identity, not its display label, import path, or a standalone hook.
  • Your component may keep a distinct name or use the same name as the target. A distinct name remains directly addressable on older CLIs that ignore replaces.
  • --package @astryxdesign/core always selects the original Core component, and --package with your package selects your component by either name.
  • Replacing SideNav replaces only that name. SideNavItem and the other SideNav parts stay Core components; document any parts your package provides under their own names.
  • swizzle --list lists Core components, including the one you replace. swizzle SideNav copies your component, and swizzle SideNav --package @astryxdesign/core copies the original.

Check replacement resolution

bash
npx @astryxdesign/cli doctor integration components
npx @astryxdesign/cli component SideNav
npx @astryxdesign/cli component AcmeSideNav
npx @astryxdesign/cli component SideNav --package @astryxdesign/core
  • Once the package declares the range, a missing target, invalid value, second replacement for one target in the same package, or a replacement named after a different Core component is an error. Without the range, doctor integration components reports each of these as a warning.
  • When several integrations replace one target, explicit configuration beats the automatic pick. Among explicitly configured integrations, the later package wins and Doctor warns. Among autolinked integrations alone, the dependency listed later in package.json wins and Doctor warns.

In global mode

In global mode: a replacement from a package installed for the computer applies in folders outside any project; inside a project, only replacements from packages the project installs apply. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Templates

In global mode: templates from an integration installed for the computer show up outside projects; inside a project, only templates from integrations the project installs. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

What a template is

Unlike a component, a template becomes app code: an app copies it into its own code and adapts it to its product.

A component stays a package dependency and updates with the package. A copied template does not, so updating the package never rewrites the app's copy.

bash
# How an app finds a template and copies it
npx @astryxdesign/cli template --list
npx @astryxdesign/cli template acme-dashboard src/app/dashboard

Why share one

Templates help other people build apps faster while keeping a consistent visual language across products. Share one when people need more than one component to get started: a template brings the right components, layout, content structure, and interaction wiring already assembled.

Do not turn a product-specific page into a rigid component only to share its structure. Share it as a template and let each app adapt its copy.

Choose a page or block

KindUse it for
PageA complete screen, such as a dashboard, settings page, or checkout flow.
BlockA smaller section that fits inside a page, such as a hero, form, or data panel. A block can also be the example or showcase for a component.

Pick a template id

Choose a stable lowercase kebab-case id, such as acme-dashboard. The id becomes the source and doc file name, the package export, and the value apps pass to npx @astryxdesign/cli template. To change a label, edit the doc (npx @astryxdesign/cli docs authoring/building-blocks/templates/document-the-template/template-doc-overview); never rename the id.

bash
# See the Core ids
npx @astryxdesign/cli --json template --list --package @astryxdesign/core

Run the add command

Pages are the default. Pass --type block to add a block.

bash
npx @astryxdesign/cli integration add template acme-dashboard
npx @astryxdesign/cli integration add template acme-stat-card --type block
text
template contribution added
​
[ok] acme-dashboard
​
Declare template root ./templates in astryx.integration.mjs.
​
- templates/acme-dashboard.doc.mjs
- templates/acme-dashboard.tsx
- package.json
- astryx.integration.mjs

The command writes templates/<id>.tsx for the UI and templates/<id>.doc.mjs for its metadata, and declares the templates directory in astryx.integration.mjs. When package.json has an exports map, it also adds the ./templates/<id> export (npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/package-and-test/export-template-assets).

It never overwrites an existing source or doc file. Add --dry-run to see every planned write first.

In global mode

In global mode: when users install your package for their computer, beside the global CLI, your templates show up in folders outside any project; inside a project, only if the project installs your package. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Understand the two files

Every template has a source file and a doc file. For acme-dashboard, the source is templates/acme-dashboard.tsx and the doc is templates/acme-dashboard.doc.mjs. Both begin with acme-dashboard, which is how Astryx knows they belong together.

FileWhat it controls
templates/acme-dashboard.tsxThe UI source that an app copies and then owns.
templates/acme-dashboard.doc.mjsHow Astryx names, describes, categorizes, previews, and resolves the template before it is copied.

The integration manifest points Astryx to the templates directory; it does not list each template. Change the source and doc together whenever the purpose, preview, readiness, or replacement behavior changes. No automated check can tell whether the doc still describes the rendered UI.

Document the shared fields

The fields below apply to every page and block. The page, block, and replacement guides add the fields unique to each.

Every field is in npx @astryxdesign/cli docs authoring template-doc.

Note: keywords needs @astryxdesign/cli 0.6.6 or later. A stable CLI before 0.6.6 rejects the field and drops that template, and one before 0.6.4 also hides your doc topics: template --list and search print one warning, and docs and build say nothing. Declare the CLI floor as an optional peer (npx @astryxdesign/cli docs authoring/ship/versioning); integration verify fails until you do.

Check how Astryx lists it

Read the package-scoped template list after every doc change. Confirm the id, visible name, description, type, readiness, and package.

bash
npx @astryxdesign/cli --json template --list --package @acme/astryx-templates
Relevant list result
json
{
"id": "acme-dashboard",
"name": "acme-dashboard",
"displayName": "Acme Dashboard",
"description": "An analytics dashboard for reviewing account health and recent trends.",
"type": "page",
"package": "@acme/astryx-templates",
"isReady": false
}

Start from the generated page doc

integration add template writes a page doc by default. Replace its sample description, then add the page-only fields below.

templates/acme-dashboard.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').IntegrationTemplateDoc} */
export default {
type: 'page',
name: 'acme-dashboard',
displayName: 'Acme Analytics Dashboard',
description:
'A filterable account-health dashboard with headline metrics, trends, and a recent-activity table. Dashboard, reporting, analytics, or status overview.',
category: 'Dashboard - Analytics',
isReady: false,
};

Help people find the page

Every field is in npx @astryxdesign/cli docs authoring template-doc.

  • Set category to the most specific supported {Group} - {Variant} value. Its words become search terms for npx @astryxdesign/cli search, and it appears in the JSON template list.
  • Set scaffold: true only for a deliberately sparse starting shell. The JSON template list reports it, so tools and galleries can tell a scaffold from a finished page.
  • Fields that only Astryx's own gallery reads, such as isHiddenFromOverview, have no effect on integration templates.

Start with a standalone block

Most blocks stand alone. Start with this shape unless the block is specifically the example or showcase for one component.

templates/acme-stat-card.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').IntegrationTemplateDoc} */
export default {
type: 'block',
name: 'acme-stat-card',
displayName: 'Acme Stat Card',
description:
'A compact metric card with a current value, period delta, and supporting trend. KPI, scorecard, summary, or dashboard statistic.',
isReady: false,
aspectRatio: 4 / 3,
componentsUsed: ['Card', 'HStack', 'Text', 'VStack'],
};

Do not add exampleFor only because the block uses a component. componentsUsed records composition; exampleFor declares that one component owns the example.

Choose the component relationship

Every field is in npx @astryxdesign/cli docs authoring template-doc.

Keep one clear primary owner. Use the also* fields only for intentional secondary placements.

templates/acme-status-card-showcase.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').IntegrationTemplateDoc} */
export default {
type: 'block',
name: 'acme-status-card-showcase',
displayName: 'Acme Status Card Showcase',
description:
'An account-health summary that demonstrates status, trend, and action states for AcmeStatusCard.',
isReady: true,
exampleFor: 'AcmeStatusCard',
isShowcase: true,
alsoExampleFor: ['AcmeDashboard'],
aspectRatio: 4 / 3,
componentsUsed: ['AcmeStatusCard', 'Button', 'HStack', 'VStack'],
};

Then check the package-scoped template list (npx @astryxdesign/cli docs authoring/building-blocks/templates/document-the-template/template-doc-overview) and confirm the entry carries the relationship you set. npx @astryxdesign/cli component does not show integration blocks, so the list is where to check.

Choose a useful preview

Every field is in npx @astryxdesign/cli docs authoring template-doc.

Set aspectRatio from the value for the closest shape below, render the block at that ratio, and adjust it until it neither clips nor leaves large empty space. These are starting points, not contract defaults.

Block shapeStarting value
Wide navigation, banner, toolbar, or tabs16 / 4
Square button, badge, avatar, icon, spinner, or status1
Tall navigation, calendar, list, or tree3 / 4
Content card, dialog, table, or form group4 / 3

scale affects only Astryx's own block previews. Integration blocks can leave it out.

Replace only on purpose

Use with care: replace a Core template only when every app using this integration should receive your source for an existing Core id by default. An alternative, a product-specific variation, or a different kind of template gets its own id instead (npx @astryxdesign/cli docs authoring/building-blocks/templates/start-a-template).

  • Sharing a Core id without replaces does not replace Core. It makes the bare id ambiguous, so npx @astryxdesign/cli template <id> fails until the app adds --package, and doctor integration templates reports an accidental collision.
  • A replacement can keep the Core id or use its own. replaces is what makes it the default.

Declare the replacement

Find the exact Core id and kind first.

bash
npx @astryxdesign/cli --json template --list --package @astryxdesign/core
templates/acme-app-shell.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').IntegrationTemplateDoc} */
export default {
type: 'page',
name: 'acme-app-shell',
displayName: 'Acme App Shell',
description:
'An Acme application shell with product navigation, account controls, and a responsive content region.',
replaces: 'shell-side-nav',
isReady: true,
category: 'Shell - Left Sidebar',
};

Every field is in npx @astryxdesign/cli docs authoring template-doc.

Declare replaces on the template doc, never in astryx.integration.mjs.

Require a compatible CLI

Declare the CLI floor that integration verify names as an optional peer (npx @astryxdesign/cli docs authoring/ship/versioning).

package.json
json
{
"peerDependencies": {
"@astryxdesign/cli": ">=0.6.4"
},
"peerDependenciesMeta": {
"@astryxdesign/cli": {
"optional": true
}
}
}

integration verify reports replaces_needs_cli when that peer is missing or too old. Without it, published 0.6.3 and earlier drop that template and hide your doc topics.

What apps receive

bash
# Receives the active replacement
npx @astryxdesign/cli template shell-side-nav src/app
​
# Receives the original Core template
npx @astryxdesign/cli template shell-side-nav --package @astryxdesign/core src/app

When several integrations replace the same Core template, one wins:

  • An integration explicitly listed in astryx.config.mjs wins over one that is only installed and picked automatically.
  • When several explicitly configured integrations replace the same target, the one listed later wins and Astryx reports the ambiguity.
  • When no integration is configured explicitly, the package listed later in the app's package.json dependencies wins. Configure the intended integration explicitly instead of relying on that order.

Check the replacement

bash
npx @astryxdesign/cli doctor integration templates
IssueMeaning
missing_template_replacement_targetreplaces does not name a Core template id.
invalid_template_replacementThe replacement is unusable or its page/block kind differs from Core.
ambiguous_template_replacementOne package declares two replacements for a target, or several active packages contend for it.

Replacement resolution is safe by default. A missing source, invalid doc, missing target, wrong kind, or conflicting declaration never hands the Core id to a questionable replacement: Astryx reports the issue and keeps the Core template. Fix every issue before publishing.

Know what gets copied

npx @astryxdesign/cli template copies exactly one source file. It does not copy sibling helpers, stylesheets, fonts, icons, images, or media. Plan for that before you add supporting files.

TemplateDirectory targetExplicit file target
PageWrites <target>/page.tsxWrites the exact file path
BlockWrites <target>/<source-basename>.tsxWrites the exact file path
bash
# Page: src/app/account/page.tsx
npx @astryxdesign/cli template acme-account src/app/account
​
# Block: src/features/account/acme-stat-card.tsx
npx @astryxdesign/cli template acme-stat-card src/features/account
​
# Exact destination for either kind
npx @astryxdesign/cli template acme-stat-card src/features/account/StatusCard.tsx

Astryx refuses to replace an existing file unless the caller passes --overwrite or -f.

Keep edited code in one file

Put every helper that the app is expected to edit in the template source. Small local components, example data, constants, and event handlers can live above or below the default component in the same file.

  • Inline a helper when it is part of the editable starting point.
  • Move a helper into the integration package only when it should stay package-owned and update with the package.
  • Do not import ./helper, ./styles, or any other sibling file.
  • Do not create routes, configuration, or extra files at runtime. Give the app one explicit source file to own.

Use imports that work in the app

After the copy, imports resolve from the app, not from the template directory. Every bare import must name a package the app installs, and every package path must be public.

tsx
import {Button} from '@astryxdesign/core/Button';
import {Card} from '@astryxdesign/core/Card';
import {HStack} from '@astryxdesign/core/HStack';
import {Text} from '@astryxdesign/core/Text';
import {VStack} from '@astryxdesign/core/VStack';
import {AcmeStatusCard} from '@acme/astryx-widgets/components/AcmeStatusCard';
​
const metrics = [
{label: 'Healthy accounts', value: '1,248'},
{label: 'Needs review', value: '37'},
];
​
function MetricRow({label, value}: {label: string; value: string}) {
return (
<HStack justify="between">
<Text>{label}</Text>
<Text>{value}</Text>
</HStack>
);
}
​
export default function AcmeAccountSummary() {
return (
<Card>
<VStack gap={4}>
<AcmeStatusCard />
{metrics.map(metric => <MetricRow key={metric.label} {...metric} />)}
<Button label="Review accounts" />
</VStack>
</Card>
);
}

Export one component

The source must default-export one React component. Named helpers inside the file are fine.

  • Add use client as the first statement only when hooks, event handlers, browser APIs, or a component passed as a prop, such as icon={BellIcon}, need a client boundary.
  • Keep a static template server-compatible when it needs no client behavior.

Assets

The copied file does not bring sibling assets with it (npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/write-the-template-file). Give every asset exactly one owner:

OwnerUse whenHow the copied source reaches it
Copied sourceThe value is small, editable, and belongs to the starting pointKeep it in the .tsx file
Integration packageThe asset should stay centrally maintained with the packageImport a stable public package path
AppThe app must provide product-specific content or brandingUse an explicit placeholder or app public URL and say what to replace

Do not make a template look self-contained while it relies on an undocumented package file or app convention.

Start with Astryx

Use Astryx component props, layout primitives, and design tokens before writing custom CSS. This keeps the copied result aligned with the host theme and reduces the styling contract an app inherits.

  • Use Stack and Grid spacing props instead of margins between children.
  • Use component variants, sizes, padding, and alignment props before restyling internals.
  • Use public theme token exports for a value that Astryx does not expose as a prop.
  • Do not copy private generated class names or target component internals with selectors.

Keep editable styles with the source

When a style belongs to the editable starting point, define it in the template file.

tsx
import * as stylex from '@stylexjs/stylex';
import {colorVars} from '@astryxdesign/core/theme/tokens.stylex';
​
const styles = stylex.create({
trend: {
color: colorVars['--color-success'],
minWidth: 0,
},
});
​
// Later: <Text xstyle={styles.trend}>Up 12%</Text>

Use custom declarations only when no Astryx prop or token fits. Each one counts in the Custom CSS category of npx @astryxdesign/cli docs authoring/building-blocks/templates/write-good-templates/template-grading-rubric.

Publish shared CSS deliberately

When a stylesheet must stay package-owned, import it from the template by its public package path.

templates/acme-dashboard.tsx
tsx
import '@acme/astryx-widgets/styles/acme-dashboard.css';

Then export that path and include the file in the package (npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/package-and-test/export-template-assets). TypeScript 6 and later check side-effect imports by default, so this import type-checks only when the app or its framework declares *.css modules.

Prefer host typography

Use Astryx typography components and theme values unless the template must demonstrate a specific licensed typeface. A copied template should normally inherit the app typography instead of installing a new global font.

  • Do not add a font only to make sample content look more polished.
  • Do not override the app body font from a template.
  • Use a custom face only for a real product or content requirement that the integration package owns.

Package a required font

Keep the font files beside a package-owned stylesheet. The stylesheet can use relative url() paths because it stays in the package, and the template imports the stylesheet by its public path (npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/template-assets/template-styles).

styles/acme-dashboard.css
css
@font-face {
font-family: 'Acme Sans';
src: url('../assets/fonts/acme-sans-regular.woff2') format('woff2');
font-style: normal;
font-weight: 400;
font-display: swap;
}
​
.acme-dashboard-title {
font-family: 'Acme Sans', sans-serif;
}
  • Ship WOFF2 when it satisfies the supported browsers. Add another format only when those browsers require it.
  • Declare every weight and style the template uses. Do not make the browser synthesize bold or italic because a file is missing.
  • Use font-display: swap unless the product requirement and performance test justify another value.
  • Confirm that the font license permits redistribution in the integration package.

Exporting the stylesheet does not publish the font files it points to. Include the font directory in the package as well (npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/package-and-test/export-template-assets).

Check fonts in the app

When you test the template in an app (npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/package-and-test/test-template-in-app), also confirm the following.

  • Every font request succeeds from the built asset path.
  • Computed styles show the requested family, weight, and style.
  • Text stays readable with the fallback stack when a font loads slowly or fails.

Use Astryx Icon

Use Icon for a standalone icon. For a component icon prop, pass what that prop accepts: most, such as Button and IconButton, take an element like <Icon icon={BellIcon} />, and some menu and navigation items also accept the icon definition itself. This keeps sizing, color, and accessibility aligned with the system.

tsx
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {BellIcon} from '@heroicons/react/24/outline';
​
<Icon icon={BellIcon} size="sm" />
<Button label="Alerts" icon={<Icon icon={BellIcon} />} />

Ship a custom icon deliberately

A product mark or missing domain symbol can stay package-owned. Export a compatible icon definition from a public package path, then import that path from the template.

templates/acme-dashboard.tsx
tsx
import {Icon} from '@astryxdesign/core/Icon';
import {AcmePulseIcon} from '@acme/astryx-widgets/icons/AcmePulseIcon';
​
<Icon icon={AcmePulseIcon} size="sm" label="Account health" />

Export the icon path and include its file in the package (npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/package-and-test/export-template-assets).

Check icons in the app

When you test the template in an app (npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/package-and-test/test-template-in-app), also confirm the following.

  • Every icon state renders, including selected, disabled, loading, and narrow layouts.
  • Meaningful icons have an accessible name, and decorative icons add no duplicate spoken text.
  • No missing icon leaves an empty control.

Do not rely on sibling media

A reference such as ./hero.png or ../assets/demo.mp4 stays unchanged in the copied file and normally breaks.

  • Use a stable public URL when the integration owner will continue to host the media.
  • Use an explicit app-owned placeholder when every app must supply product-specific media.
  • Use a package-owned JS or CSS entrypoint only when every supported app toolchain can resolve and emit the underlying asset.
  • Do not assume that a file inside node_modules is automatically served at a browser URL.

Understand `/template-assets`

/template-assets/<path> is a preview-only fixture path. During copy, Astryx replaces a complete static image reference with an inline neutral SVG placeholder. It replaces a complete static video reference with an empty string because it cannot create a valid inline video.

Source referenceCopied result
/template-assets/hero.pngInline SVG data URL placeholder
/template-assets/demo.mp4Empty string
./hero.png or /product/hero.pngUnchanged
https://example.com/template-assets/hero.pngUnchanged
  • Supported preview image suffixes are SVG, PNG, JPG, JPEG, GIF, WebP, AVIF, and ICO.
  • Supported preview video suffixes are MP4, WebM, MOV, OGV, and M4V.
  • Keep the preview path as one complete static string. Concatenation, interpolation, method calls, module imports, and unknown file formats are rejected when Astryx cannot replace them safely.
  • Use this path only when a placeholder is the intended copied result. It is not a way to ship required product media.
  • Astryx serves /template-assets only in its own docs. A preview you host must serve that path itself.

Design a useful placeholder

When the app must provide the final asset, keep the replacement point obvious in the copied source. Preserve enough surrounding layout and example data that the template still teaches the pattern without pretending the final content is included.

tsx
const HERO_IMAGE = '/acme-dashboard-hero.jpg';
// Replace HERO_IMAGE with an app-owned public URL before shipping the page.
​
<img
src={HERO_IMAGE}
alt="Account health trends for the current quarter"
width={1200}
height={675}
/>
  • Use accurate alternative text for informative images and alt="" for decoration.
  • Provide dimensions or an aspect-ratio container so loading does not shift the page.
  • For video, provide controls, a useful poster, and captions when the content requires them.
  • Do not leave an inaccessible, expiring, authenticated, or environment-specific URL in copied source.

Check media in the app

When you test the template in an app (npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/package-and-test/test-template-in-app), also confirm the following.

  • The copied source shows the placeholder substitutions you expect.
  • The network log has no missing, redirected, blocked, or unauthorized media requests.
  • Each placeholder keeps the layout and says what the app must replace.

Keep the generated template export

integration verify resolves each template through a public package path without a file extension, such as ./templates/acme-dashboard, and fails when that path is missing.

package.json
json
{
"exports": {
"./templates/acme-dashboard": "./templates/acme-dashboard.tsx"
}
}
  • integration add writes this entry when package.json has an exports map (npx @astryxdesign/cli docs authoring/building-blocks/templates/start-a-template). Without a map it writes nothing, because creating one would make existing deep imports private. Add the map yourself, then the entry.
  • Keep the public path without .tsx. Only the target file ends in .tsx.
  • Do not point the entry at a barrel file that loses the template default export.

Export package-owned support files

Add a public path for every package-owned component, helper, icon, or stylesheet that the copied source imports. Keep implementation-only files private.

Relevant package.json entries
json
{
"exports": {
"./templates/acme-dashboard": "./templates/acme-dashboard.tsx",
"./components/AcmeStatusCard": "./components/AcmeStatusCard.tsx",
"./icons/AcmePulseIcon": "./icons/AcmePulseIcon.tsx",
"./styles/acme-dashboard.css": "./styles/acme-dashboard.css"
}
}

If a block is also loaded directly as a live .tsx preview, add a pattern that keeps the extension, such as "./templates/*.tsx": "./templates/*.tsx". Keep the generated entry too; integration verify resolves the template through it.

Include every file

An export can point to a file that npm leaves out. When package.json#files exists, list the integration manifest, the templates directory, and every directory the copied file depends on.

package.json
json
{
"files": [
"astryx.integration.mjs",
"templates",
"components",
"icons",
"styles",
"assets/fonts"
],
"sideEffects": ["**/*.css"]
}
  • integration add appends the manifest and templates directory when a files list already exists. Add the other directories yourself.
  • If package.json sets sideEffects, include CSS in it, as above, so bundlers keep side-effect stylesheet imports.
  • Run npm pack --dry-run and read the real file list instead of reasoning from the workspace.

Declare what the copied file imports

A copied file imports from the app, so the app must install every package it imports. Declare the Astryx peers as described in npx @astryxdesign/cli docs authoring/ship/versioning. For other packages:

FieldUse it for
peerDependenciesLibraries the copied source imports, such as an icon library. List them in devDependencies too, so the package itself builds.
dependenciesRuntime packages used only by package-owned modules, which should travel with the integration
devDependenciesEverything needed to build and test the integration itself

Use only ranges you test. A package that resolves only because a workspace hoisted it is not declared.

Run the package check

bash
npx @astryxdesign/cli integration verify

integration verify runs npm pack, including lifecycle scripts, unpacks the .tgz file into a temporary app without installing its dependencies, and checks the unpacked integration. It publishes nothing and removes the .tgz file and the temporary app.

  1. It validates the manifest, every template doc, its source file, and the templates directory.
  2. It checks that the manifest and every template file are in the .tgz file.
  3. It checks that each packed template keeps the same id, type, name, and replaces as your working copy. It does not compare template source or other doc fields.
  4. It resolves each template through its public package path in the unpacked package.
  5. It checks that the resolved template module has a default export.
  6. It checks the CLI peer that features such as template replacement require.

Each failure prints a [fail] line with its message; add --json to see each issue code. npx @astryxdesign/cli docs authoring/help/troubleshooting lists the common messages and their fixes. Fix every error, and review every warning before publishing.

Know what it does not prove

integration verify checks the packed templates and their public entrypoints. It does not copy a template into an app or run a browser build, so it cannot show the following.

  • Whether a relative helper or stylesheet survives the copy.
  • Whether the copied file type-checks in every supported app toolchain.
  • Whether package-owned CSS, fonts, icons, images, or media load in a browser.
  • Whether layout, color modes, keyboard and pointer input, and real content work.
  • Whether a template id collides with Core, or whether a replaces target exists. Run npx @astryxdesign/cli doctor integration templates for those.
  • How the template scores on quality.

Install the packed package

Install the packed package in a clean app as described in npx @astryxdesign/cli docs authoring/ship/test-in-an-app. Also install every library the copied source imports, such as its icon library. Pack and reinstall after every change.

Copy the template

bash
# Confirm discovery and metadata
npx @astryxdesign/cli --json template --list --package @acme/astryx-widgets
​
# Inspect source without writing
npx @astryxdesign/cli template acme-dashboard --package @acme/astryx-widgets
​
# Copy a page and a block to their real app locations
npx @astryxdesign/cli template acme-dashboard --package @acme/astryx-widgets src/app/acme-dashboard
npx @astryxdesign/cli template acme-stat-card --package @acme/astryx-widgets src/components
  • Confirm the copied files: src/app/acme-dashboard/page.tsx and src/components/acme-stat-card.tsx.
  • Inspect the copied source for placeholder substitutions and relative references.
  • Import the copied block into a real page. A file that is never imported has not been tested.

Build every supported app

Run the type check (npx tsc --noEmit, or the app's own script) and the production build in every framework or bundler the integration supports. One toolchain passing does not prove another.

bash
npx tsc --noEmit
npm run build
  • Every import resolves from the copied location.
  • Production builds keep required side-effect CSS.
  • No import bypasses exports, and no dependency resolves only through workspace hoisting.

Render the behavior matrix

DimensionRequired checks
WidthWide and narrow; include the smallest supported viewport
ColorEvery supported color mode and theme
InputKeyboard and pointer for the primary task
ContentRealistic, empty, long, loading, and error states that the pattern supports
AssetsSuccessful CSS, font, icon, image, poster, and video requests
RuntimeNo console error, hydration mismatch, or missing-provider failure

Check the rendered hierarchy, reading order, focus order, clipping, overflow, and fallback states. A screenshot of one default state is not a complete test.

Grade the result

Grade the exact packed revision using the evidence from this test (npx @astryxdesign/cli docs authoring/building-blocks/templates/write-good-templates).

How to write good templates

A good template does more than render. It gives an app a clear product starting point that is easy to understand, safe to change, and complete after Astryx copies it out of the package. A rubric scores that quality so you can grade as you build.

  • Its purpose and primary task are clear before someone reads every detail.
  • It composes Astryx components instead of rebuilding their behavior with raw HTML or custom styles.
  • Its hierarchy, spacing, interactions, and reading order still work at narrow widths and in every supported color mode.
  • Its source, imports, styles, fonts, icons, images, and media still work from the copied location.
  • Its metadata makes the template easy to find and accurately explains when to use it.
  • Its example content is realistic enough to expose overflow, empty-space, and hierarchy problems.

Grade the first runnable version, again after each source, doc, dependency, or asset change, and in full before every release. Grade the source, doc, copied file, and rendered app together; none of them is enough alone.

  1. Read every scoring rule in npx @astryxdesign/cli docs authoring/building-blocks/templates/write-good-templates/template-grading-rubric.
  2. Fix publication blockers first, then every reasonable deduction.
  3. Repeat the full grade on the packed package in a clean app (npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/package-and-test/test-template-in-app).
  4. To have an agent grade and improve the template, use npx @astryxdesign/cli docs authoring/building-blocks/templates/write-good-templates/grade-template-with-agent.

Keep each scorecard with its package revision and rubric version.

Understand the score

Start with npx @astryxdesign/cli docs is-my-code-good: it owns the shared Astryx quality criteria. This rubric adds template weights, counting rules, and template-only checks, and each category that scores a shared criterion names it.

Template rubric 1.4 scores seven categories for 100 points. Aim for 100; B (75) is the publication floor, not the target. Each category below lists its weight. Never award points for anything you did not inspect.

GradeScoreMeaning
A90-100Exemplary. Copy-ready with no known quality problems.
B75-89Good. Minor issues may remain, but the template is usable.
C60-74Needs work before publication.
D40-59Poor. Significant rewrites are needed.
F0-39Failing. The template teaches or produces bad patterns.

Keep improving while a deduction has a reasonable fix. A score below 100 is fine only when the remaining tradeoff is intentional and recorded. A template is not ready if the copied file fails to build or a required asset is missing, whatever its score.

Version 1.4 counts public integration components as Astryx components and grades assets and imports after the copy. Record the version with every score so results stay comparable.

Astryx component purity: 30 points

This category scores the Astryx components criterion in npx @astryxdesign/cli docs is-my-code-good. Count every JSX opening tag in the copied .tsx source. An Astryx element is a component imported from @astryxdesign/core or from a public component export of the integration package. A raw HTML element is any lowercase intrinsic JSX tag. Count occurrences, not only unique tag names.

Do not count fragments or a PascalCase helper defined in the same file. Inspect that helper and count the raw HTML inside it. For each raw element, decide with that guide whether it is necessary because Astryx has no equivalent, or unnecessary because an Astryx component can replace it.

Raw HTML elementsPoints
030
1-2, all necessary25
1-2, any unnecessary20
3-515
6-108
11-204
21 or more0
  • Do not deduct for an img when no general Astryx image component fits and its source passes the Image handling category.
  • Do not deduct for a form that wraps FormLayout to provide native submission semantics.
  • Do not deduct for input type="hidden" when it carries native form state.

Icon purity: 15 points

This category scores the Icons criterion in npx @astryxdesign/cli docs is-my-code-good. Count raw icon markup in the copied file. npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/template-assets/template-icons shows how to render icons through Astryx instead.

  • Count every raw svg, path, circle, rect, line, polyline, polygon, ellipse, or g used as an icon.
  • Count an inline SVG component defined in the template.
  • Count an icon component rendered directly instead of through Icon or an Astryx icon prop.
Raw SVG icon instancesPoints
015
1-210
3-55
6 or more0

Custom CSS: 15 points

This category scores the Styling criterion in npx @astryxdesign/cli docs is-my-code-good. Count individual CSS properties inside stylex.create and inline style objects. Count each className and stylex.props use once. Do not count Astryx props such as gap, padding, variant, size, color, level, columns, contentPadding, or height.

Custom style declarationsPoints
015
1-3, all justified because no Astryx alternative exists12
1-3, any unjustified because an Astryx prop exists8
4-105
11-202
21 or more0

This category scores styles authored in the copied source. A package stylesheet is graded through its effect on portability and the rendered app, not as a way to hide custom declarations from this count.

Layout and structure: 15 points

Page templates

  1. Choose the page root with the Layout criterion in npx @astryxdesign/cli docs is-my-code-good. In a template, only a category that starts with Shell - owns global chrome, so it uses AppShell; every other page template uses Layout or Center.
  2. Outside a Shell - template, leave global navigation to the host app. Put in-page navigation in a LayoutPanel and page headings in LayoutHeader.
  3. Use Grid with columns={{minWidth: 280}} for responsive collections. Do not fix the column count or rebuild the grid in raw CSS.
  4. Use Center for centered content instead of custom flexbox workarounds.
  5. Render one page from one source file. Links may be inert examples, but the template does not create nested routes or router integration.
Page conditionPoints
Correct root, responsive grids, proper centering, and one page15
Valid root with a smaller issue such as fixed columns or multi-page behavior8
Wrong root for the category, a raw layout root, or no Astryx root0

Block templates

  1. Do not wrap a block in AppShell. A block renders inside a preview or page container.
  2. Keep the block focused on one pattern or component usage.
  3. Keep the composition substantial enough to teach the pattern and small enough to adapt. About 20-100 lines is the normal range.
Block conditionPoints
No AppShell, one focused pattern, and a reasonable length15
A smaller focus or length issue10
Wrapped in AppShell or deeply unfocused0

Doc metadata: 10 points

Score field accuracy for 6 points, the description for 3 points, and naming for 1 point. Read the source and doc together. A field that exists but disagrees with the source is not complete.

Fields: 6 points

Field conditionPoints
All applicable fields are present and accurate6
All fields are present with one inaccuracy4
One required field is missing2
Two or more required fields are missing, or no doc exists0

Description: 3 points

A strong description covers four slots: the archetype, the job someone does, the structural or behavioral differentiator, and the alternate words people may search. Use at least six distinct content words after removing generic words such as page, screen, app, view, and component names. Describe the reusable shape, not only the sample data.

Description conditionPoints
All four slots, 6 or more distinct content words, and clear separation from sibling templates3
Names and differentiates the pattern but misses one slot or leaves a synonym implicit2
Generic, repeats component names, or describes only the sample data1
Missing or restates the name0

Naming: 1 point

The id follows npx @astryxdesign/cli docs authoring/building-blocks/templates/start-a-template, displayName is readable, and a browsable page has a specific category (npx @astryxdesign/cli docs authoring/building-blocks/templates/document-the-template/page-template). Slug length is guidance, not a scored condition.

Naming conditionPoints
Id, display name, and applicable category follow the convention1
Any naming or category requirement is missed0

Image handling: 5 points

This category scores the Images and assets criterion in npx @astryxdesign/cli docs is-my-code-good. Inspect every image reference in the copied file against npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/template-assets/template-images-media.

Image conditionPoints
No image is needed, or every image still works after copy and the demo-placeholder behavior is intentional5
One optional demo image is missing in preview, or a placeholder service remains2
An essential image breaks after copy, uses a package-relative path, or depends on an inaccessible URL0

Code quality: 10 points

This category scores the Code health criterion in npx @astryxdesign/cli docs is-my-code-good, plus conditions only a template has. Award 2 points for each condition. Grade the copied file, not only the package source.

ConditionPointsHow to verify
Correct client boundary2use client is the first executable statement when hooks, event handlers, or browser APIs require it. A static template does not add it without need.
Default export2The copied file has one default-exported React component.
Self-contained imports2Every import resolves from the copied location through React, a public Astryx path, a public integration-package export, or an app dependency the template explicitly requires.
Realistic example data2Content has realistic names, amounts, dates, lengths, and states instead of lorem ipsum or numbered placeholders.
No dead code2There are no unused imports or variables, commented-out blocks, or helpers that are never called.

Give the agent the rubric

Grade one exact package revision against template rubric 1.4. Give the agent the template id, the package name, and a clean app that can install the packed package, then use this prompt. Replace every angle-bracket value. The first pass is read-only so the original score and findings stay visible.

Agent grading prompt
text
Grade integration template <id> from <package> at <revision>.
​
Before scoring:
1. Read `npx @astryxdesign/cli docs authoring/building-blocks/templates/write-good-templates --full`.
2. Read `npx @astryxdesign/cli docs authoring/building-blocks/templates/write-good-templates/template-grading-rubric --full` and every guide it links.
3. Inspect the template source, its matching .doc.mjs file, package.json exports and files, and astryx.integration.mjs.
4. Run `npx @astryxdesign/cli integration verify` in the package.
5. Follow `npx @astryxdesign/cli docs authoring/building-blocks/templates/build-the-template/package-and-test/test-template-in-app --full`: install the packed package in the clean app, copy the template, build the app, and render the behavior matrix.
​
Score all seven rubric categories. Cite file and line evidence for every deduction. Do not award points for a state you did not inspect. If browser or build evidence is unavailable, say so and mark the template not publishable.
​
Return the rubric version, package revision, numeric score, letter grade, category scorecard, publishable yes/no verdict, detailed findings, and the top three fixes. Treat 100 as the target. Name every remaining deduction and say whether it is an intentional tradeoff or still needs work. Do not edit files during this first pass.

Require a scorecard

markdown
# Template grade: <id>
​
**Rubric:** 1.4
**Package revision:** <revision>
**Type:** page | block
**Grade:** <letter> (<score>/100)
**Publishable:** yes | no
​
| Category | Points | Max | Evidence |
| --- | ---: | ---: | --- |
| Astryx component purity | X | 30 | |
| Icon purity | X | 15 | |
| Custom CSS | X | 15 | |
| Layout & structure | X | 15 | |
| Doc metadata | X | 10 | |
| Image handling | X | 5 | |
| Code quality | X | 10 | |
| **Total** | **X** | **100** | |
​
## Detailed findings
- <file:line, rubric rule, observed problem, and deduction>
​
## Publication blockers
- <failed pack, copy, build, asset, browser, theme, responsive, or input check>
​
## Top three fixes
1. <highest-value fix>
2. <second fix>
3. <third fix>

The category scores must add exactly to the total, and the grade comes from the published bands. The verdict is yes only when the score is 75 or higher and every package, copy, build, asset, and rendered-app check passes.

Improve and regrade

  1. Fix publication blockers first.
  2. Fix every reasonable deduction without hiding it behind helper components or package CSS.
  3. Pack again, copy into a fresh app, and have the agent repeat the full grade. Keep the earlier scorecard so the improvement is visible.
  4. Stop only when no reasonable fix remains, and record every intentional tradeoff that still costs points.

Themes

In global mode: outside projects, the theme list includes themes from integrations installed for the computer, and theme add there only shows them; inside a project, it lists only themes from integrations the project installs. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

A theme contribution has editable defineTheme source plus a built module and production CSS. integration add theme scaffolds the source and declares the package exports; theme build writes the built outputs. An app installs the package, runs theme add, applies the theme from its generated app module, and customizes it with extends instead of copying source.

These guides cover the whole path: scaffold the source, generate its palette, define and build the theme, ship fonts and assets, run integration verify, and document how apps add and extend it. Applying a theme, including mode and SSR, works the same for every theme; see npx @astryxdesign/cli docs use-a-theme.

Add a theme

Run integration add theme with a lowercase kebab-case slug. It writes a blank theme — a defineTheme skeleton and its descriptor — into a folder named after the slug.

bash
npx @astryxdesign/cli integration add theme ocean
text
theme contribution added
​
[ok] ocean
​
Declare theme root ./themes in astryx.integration.mjs.
​
- themes/ocean/oceanTheme.ts
- themes/ocean/oceanTheme.doc.mjs
- package.json
- astryx.integration.mjs

oceanTheme.ts exports oceanTheme, a defineTheme source, and oceanTheme.doc.mjs is the descriptor that theme list reads. The command also declares ./themes/ocean and ./themes/ocean.css package exports. Every field is in npx @astryxdesign/cli docs authoring theme-doc.

The source and built outputs ship in your package. Build the source with theme build, then run integration verify before publishing. An app runs theme add to record and import the built module and stylesheets; theme eject creates an independent source fork. See npx @astryxdesign/cli docs authoring/building-blocks/themes/use-a-theme-in-an-app.

Start from an existing theme

To begin from an existing theme instead of a blank one, pass --from. It copies that theme as your starting point to diverge from — a fork, with no link back.

bash
npx @astryxdesign/cli integration add theme ocean --from neutral

The copy keeps the packages the base theme uses, such as lucide-react for its icons: --from adds them to your dependencies, so an app that installs your package gets them too. Install your dependencies again before you build the theme.

Use --from when you want to change a lot. For a small change that should stay linked to a base theme, use extends instead (npx @astryxdesign/cli docs authoring/building-blocks/themes/define-the-theme).

Ship several themes

A package can ship more than one theme. Run integration add theme once per slug; each theme gets its own folder and descriptor, and theme list shows them all.

In global mode

In global mode: when users install your package for their computer, beside the global CLI, your themes are listed in folders outside any project, where theme add lists but writes nothing; inside a project, only if the project installs your package. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Generate a palette

A theme needs dozens of related colors — shades of each brand color for backgrounds, borders, text, and states, in both light and dark mode. A palette is that full set of shades. Rather than hand-pick every one, you name a few seed colors and generate the rest, so the shades stay consistent.

You write a short config naming each color family and its seed, then run theme palette generate. It expands each seed into a full light and dark ramp — the scale of shades — and writes the result into the theme folder, where your theme tokens point at it (see npx @astryxdesign/cli docs authoring/building-blocks/themes/define-the-theme). Keep the config and the generated palette together in that folder.

The smallest config is one color family: an id to name it and a seed color to grow it from. Save it as themes/ocean/palette.config.json:

json
{"families": [{"id": "ocean", "seed": "#0074e2"}]}
bash
# Print the palette without writing files
npx @astryxdesign/cli theme palette generate themes/ocean/palette.config.json
# Write the palette and its receipt
npx @astryxdesign/cli theme palette generate themes/ocean/palette.config.json \
--out themes/ocean/tokens/ocean.palette.ts
text
[ok] Wrote themes/ocean/tokens/ocean.palette.ts
​
[ok] Wrote themes/ocean/tokens/ocean.palette.receipt.json
​
Review and edit the candidate before adopting it as theme-owned palette data.

The command writes two files. ocean.palette.ts is the palette your theme tokens will read: it exports black, white, and palette — a ramp of 21 shades (stops 0 to 100) for both light and dark. The .receipt.json beside it records the exact seeds and settings, so you can regenerate the same palette later. A second run leaves both files alone unless you pass --overwrite.

Add --preview <file>.html to also get a web page showing every shade, so you can eyeball the palette in a browser. Write it outside themes/ so it does not ship in your package. Every option is in npx @astryxdesign/cli docs cli/commands/theme-palette-generate, and the config it reads is described in npx @astryxdesign/cli docs cli/api/functions/generate-tonal-palette.

Map the palette to tokens

A theme token is a named slot that Astryx components read for a value — --color-accent for the accent color, and so on. Components never read your palette directly; they read tokens. Defining a theme means pointing each token at a palette shade, so the components wear your colors.

Import the palette in oceanTheme.ts and point your tokens at its stops. Each token takes a [light, dark] pair — the shade to use in light mode and the one in dark mode.

ts
// themes/ocean/oceanTheme.ts
import {defineTheme} from '@astryxdesign/core/theme';
import {palette} from './tokens/ocean.palette';
​
const {light, dark} = palette.ocean;
​
export const oceanTheme = defineTheme({
name: 'ocean',
tokens: {
'--color-accent': [light['45'], dark['70']],
},
});

Local imports must stay inside the theme folder. One that leaves it, such as ../../shared/colors, fails with invalid_theme, and every theme in the package disappears from theme list. npx @astryxdesign/cli theme template writes a file that explains every defineTheme field. For the full token set and component theming, read npx @astryxdesign/cli docs author-a-theme.

Build on another theme

To base a theme on an existing one, extends it: import the base theme and override only the tokens you change. Add the derived theme with integration add theme ocean-contrast, and import the base from its built package export, since local imports cannot leave the theme folder. The derived theme keeps a live link to the base and picks up its later changes when you rebuild it — unlike --from, which forks a copy (npx @astryxdesign/cli docs authoring/building-blocks/themes/add-a-theme).

ts
// themes/ocean-contrast/oceanContrastTheme.ts
import {defineTheme} from '@astryxdesign/core/theme';
import {oceanTheme} from '@acme/astryx-widgets/themes/ocean';
​
export const oceanContrastTheme = defineTheme({
name: 'ocean-contrast',
extends: oceanTheme,
tokens: {
'--color-accent': ['#0051a3', '#4aa3ff'],
},
});

Keep light-dark() to colors

Each [light, dark] pair compiles to a light-dark() value, which switches only colors. Give it colors. For a value that is not a plain color — a gradient — put light-dark() on each color stop, not around the whole value: light-dark() takes two colors, so wrapping whole gradients makes the value invalid where the token is used, and theme build does not warn.

Do not set Core private variables

Core private variables start with --_, such as --_field-radius. They are internals and can change in any Core release, so theme build reports each one it finds as an [error]. Set the standard CSS property instead — borderRadius, padding — and let the build emit the internal variable where Core needs it.

Declare the peer dependencies

The theme imports @astryxdesign/core/theme, so declare Core as a peer dependency, with the range of Core versions you test the theme against — a range, not an exact version, so a Core patch release does not force a republish. integration add theme already declared the optional @astryxdesign/cli peer that reads themes:

json
"peerDependencies": {
"@astryxdesign/core": "^0.7.0",
"@astryxdesign/cli": ">=0.6.4"
},
"peerDependenciesMeta": {
"@astryxdesign/cli": {"optional": true}
}

Name the font

A theme names its typefaces in typography: body, heading, and code, each with a family and fallbacks. These set the --font-family-* tokens every component reads. Always give real fallbacks so text stays readable before the font loads, or if it never does. heading inherits family and fallbacks from body when you omit them.

ts
// themes/ocean/oceanTheme.ts
export const oceanTheme = defineTheme({
name: 'ocean',
typography: {
body: {family: 'Acme Sans', fallbacks: 'system-ui, sans-serif'},
code: {family: 'Acme Mono', fallbacks: 'ui-monospace, monospace'},
},
// ...your tokens
});

The full type scale and font roles are in npx @astryxdesign/cli docs author-a-theme.

Ship the font loader

Naming a family does not load it. When the theme uses non-system fonts, add <slug>.fonts.css beside the built module and production CSS, then export it as ./themes/<slug>.fonts.css. theme add imports that stylesheet with the built theme.

json
"exports": {
"./themes/ocean": "./themes/ocean/ocean.js",
"./themes/ocean.css": "./themes/ocean/ocean.css",
"./themes/ocean.fonts.css": "./themes/ocean/ocean.fonts.css"
}

The stylesheet can contain self-hosted @font-face rules or import a hosted stylesheet. A hosted @import is simple, but it delays loading compared with a preconnected <link>; choose that trade-off deliberately. Doctor warns when it cannot prove the loader — a hosted @import, or no font stylesheet while the app loads the family itself — but the warning does not fail the app.

css
/* themes/ocean/ocean.fonts.css */
@font-face {
font-family: 'Acme Sans';
src: url('./fonts/acme-sans.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}
  • Load every weight and style the theme uses. Do not let the browser synthesize bold or italic.
  • Include self-hosted font files and their licenses in the packed package.
  • Keep CSS and font assets side-effectful so a bundler does not remove the loader.
  • Run integration verify; it fails when an exported font stylesheet cannot resolve, but it does not check the font files the stylesheet names.

Verify in an app

Before publishing, run integration verify. Then install the package in a clean app, run theme add, apply the generated theme, and open it in a browser. The source, packed exports, and applied result form one chain; check the last step too.

  • Text renders in the named families at every weight and style — the italic face resolves, not a synthesized slant.
  • Every font request succeeds; nothing silently falls back to a system font.
  • Color pairs still meet contrast in both light and dark mode.

Apply the theme

Install the integration, then run theme add with its slug and package. The command records the package owner and regenerates one app theme module that imports the built theme, production CSS, and optional font CSS. Use theme eject only to copy source into an independent local fork.

bash
npm install @acme/astryx-widgets
npx @astryxdesign/cli theme add ocean --package @acme/astryx-widgets
tsx
import {Theme} from '@astryxdesign/core/theme';
import {themes, defaultThemeSlug} from './astryx-themes';
​
<Theme theme={themes[defaultThemeSlug]}>
<App />
</Theme>

theme list shows the available, added, and default themes. Use theme use <slug> to change the default, theme remove <slug> to stop importing one, and theme eject <slug> only when the app needs an independent source fork. Applying a theme — mode, SSR, and the production build — works the same for every theme; see npx @astryxdesign/cli docs use-a-theme.

Extend to customize

For ordinary customization, do not copy the package source. Import the built theme and derive a new one with defineTheme({extends: importedTheme, ...}), overriding only the tokens the app changes. Use theme eject only when the app must own an independent source fork. See npx @astryxdesign/cli docs authoring/building-blocks/themes/define-the-theme.

ts
import {defineTheme} from '@astryxdesign/core/theme';
import {oceanTheme} from '@acme/astryx-widgets/themes/ocean';
​
export const productTheme = defineTheme({
name: 'product',
extends: oceanTheme,
tokens: {'--color-accent': ['#0051a3', '#4aa3ff']},
});

In global mode

In global mode: install the theme package in each app that uses the theme; inside a project, the CLI ignores packages installed for the computer. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Extend the core theme topic

Give your theme a doc topic that extends the core theme topic, so an app that lists your package sees your theme at the end of npx @astryxdesign/cli docs theme. Add it with extends: 'theme' (npx @astryxdesign/cli docs authoring/building-blocks/docs/extend-or-replace).

javascript
// docs/ocean-theme.doc.mjs
/** @type {import('@astryxdesign/cli/authoring').ReferenceDoc} */
export default {
type: 'generic',
name: 'ocean-theme',
extends: 'theme',
title: 'Ocean theme',
description: 'Use the Ocean theme from @acme/astryx-widgets.',
sections: [
{
id: 'use-the-ocean-theme',
title: 'Use the Ocean theme',
content: [
{
type: 'prose',
text: 'Install @acme/astryx-widgets, then run astryx theme add ocean --package @acme/astryx-widgets and apply it from the generated app theme module.',
},
],
},
],
};

Keep the section short: how to install the package, run theme add, apply the generated module (npx @astryxdesign/cli docs authoring/building-blocks/themes/use-a-theme-in-an-app), and customize with extends. When the theme uses a custom font, name the families and the optional exported font stylesheet that theme add imports (npx @astryxdesign/cli docs authoring/building-blocks/themes/fonts-and-assets). Extend theme rather than a topic another package replaces, or an app that loads that package after yours drops your section.

Not agent guidance

Do not put theme usage in agentDocs. Agent lines land in every app's agent file and are for guidance needed every session; a theme's install-and-use steps belong in a doc topic that people and agents read on demand. See npx @astryxdesign/cli docs authoring/building-blocks/configuration/agent-guidance.

Docs

In global mode: docs from an integration installed for the computer show up outside projects; inside a project, only docs from integrations the project installs. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Add a topic with the CLI

Run integration add doc in your package to add a topic. It writes the topic file and declares the docs root in astryx.integration.mjs.

bash
npx @astryxdesign/cli integration add doc deploying
# Read it the way an app will
npx @astryxdesign/cli docs deploying
text
doc contribution added
​
[ok] deploying
​
Declare doc root ./docs in astryx.integration.mjs.
​
- docs/deploying.doc.mjs
- astryx.integration.mjs

Write the sections

A topic is a plain object with type: 'generic', a name, a title, a one-sentence description, and sections. Each section has a title and a list of content blocks.

docs/deploying.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').ReferenceDoc} */
export default {
type: 'generic',
name: 'deploying',
title: 'Deploying',
description: 'Ship an app built with Acme widgets.',
sections: [{
id: 'build-before-you-ship',
title: 'Build before you ship',
content: [
{type: 'prose', text: 'Build the app, then upload the `dist` folder.'},
{type: 'code', lang: 'bash', code: 'npm run build'},
{type: 'list', style: 'unordered', items: ['Keep `dist` out of git.']},
{type: 'table', headers: ['Variable', 'Value'], rows: [['`NODE_ENV`', '`production`']]},
],
}],
};

The sample sets a section id, so package.json needs the CLI peer "@astryxdesign/cli": ">=0.6.4". Published 0.6.3 and earlier reject id and hide every doc topic your package ships, and integration verify fails without that peer; see npx @astryxdesign/cli docs authoring/ship/versioning.

Write commands without a runner

Start every CLI command in a doc with the word astryx alone, with no runner such as npx or pnpm exec in front. The CLI prefixes each one when it prints the doc, with the command the reader runs: pnpm exec astryx or yarn astryx from their package manager, or the cliCommand an integration sets. See npx @astryxdesign/cli docs authoring/building-blocks/configuration/cli-command. doctor integration validate (and so integration check) lists each command written with a runner as a runner_command note, with its file, line and the bare command to write. A note fails nothing. An install step, which runs before the CLI is installed, is the one exception: write the scoped package name, @astryxdesign/cli, after the download runner. The unscoped name after a download runner is always listed, because it fetches an unrelated package.

docs/deploying.doc.mjs
javascript
{type: 'prose', text: 'Check the app with `astryx doctor` before you ship.'},
{type: 'code', lang: 'bash', code: 'astryx doctor'},
Read in an app whose integration sets cliCommand: 'acme astryx'
text
Check the app with `acme astryx doctor` before you ship.
​
```bash
acme astryx doctor
```
  • The CLI prefixes an inline code span that starts with the word astryx, a command at the start of a line, or after &&, ||, ; or |, in a bash, sh, shell, zsh, console, text or markdown code block or one with no language, and a table cell that holds only a command.
  • Code in other languages, such as a js example, and the word astryx in plain prose stay as written.
  • --json returns the doc as written, so a reader that shows it prefixes the commands itself.

Pick the doc kind

Every doc is a .doc.mjs file whose type says what it describes. integration add writes the right type and file for each kind.

You document`type`File that `integration add` writes
A guide or topic'generic'docs/deploying.doc.mjs
Your package's docs section'namespace'docs/acme.doc.mjs
A component'component'components/AcmeCarousel.doc.mjs, beside AcmeCarousel.tsx
A template'page' or 'block'templates/acme-dashboard.doc.mjs, beside acme-dashboard.tsx
A theme'theme'themes/ocean/oceanTheme.doc.mjs, beside oceanTheme.ts

Only guides, topics, and your docs section go in the docs root. The others have their own guides: npx @astryxdesign/cli docs authoring/building-blocks/components, npx @astryxdesign/cli docs authoring/building-blocks/templates, and npx @astryxdesign/cli docs authoring/building-blocks/themes.

Keep topics in the docs root

The docs field in astryx.integration.mjs names the folder that holds your topics. The CLI reads every .doc.mjs file under it, in subfolders too.

astryx.integration.mjs
javascript
export default {
docs: './docs'
};

In global mode

In global mode: when users install your package for their computer, beside the global CLI, your doc topics show up in folders outside any project; inside a project, only if the project installs your package. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Add a docs section

Give your package its own section in the docs tree with integration add doc <name> --parent <section>. The first run also writes the section's namespace doc.

bash
npx @astryxdesign/cli integration add doc deploying --parent acme
# Open your section
npx @astryxdesign/cli docs acme
docs/acme.doc.mjs
javascript
/** @type {import('@astryxdesign/cli/authoring').NamespaceDoc} */
export default {
type: 'namespace',
name: 'acme',
title: 'Acme',
summary: 'Guides for Acme.',
slots: {
guides: {title: 'Guides', accepts: {kinds: ['generic']}},
},
};
  • Edit its title and summary: readers see them in the docs list and at the top of your section.
  • The guide, docs/deploying.doc.mjs, gets placement: {parent: 'namespace:acme', slot: 'guides'}. Later runs with --parent acme reuse the namespace doc.
  • package.json gets the optional peer "@astryxdesign/cli": ">=0.6.4", because an older CLI does not read sections; see npx @astryxdesign/cli docs authoring/ship/versioning.

Place a doc

A guide names its one home with placement: a namespace of your package, a slot in it, and an order. Its route is the section name, then the guide name.

javascript
placement: {parent: 'namespace:acme', slot: 'guides', order: 10}, // route: acme/deploying
bash
npx @astryxdesign/cli docs acme/deploying

Fix a failed placement

A failed placement hides the doc: it gets no route and does not show in the docs list. doctor integration docs fails with invalid_doc_graph and names what to fix.

bash
npx @astryxdesign/cli doctor integration docs
text
severity: [fail]
code: invalid_doc_graph
message: @acme/astryx-widgets/deploying.doc.mjs: placement.parent "namespace:cli" names no namespace; @acme/astryx-widgets declares "acme".

Write {@link [provider:]kind:name} in prose, list items, and table cells to link another doc. The CLI prints the command that opens it, so the link keeps working when the doc moves.

javascript
{type: 'prose', text: 'Before you ship, read {@link generic:deploying}.'},
{type: 'list', style: 'unordered', items: ['All guides: {@link namespace:acme}.']},
{type: 'table', headers: ['Task', 'Guide'], rows: [['Ship', '{@link generic:deploying}']]},

Each link reads as a command. This link, npx @astryxdesign/cli docs authoring/building-blocks/docs/extend-or-replace, opens the next guide.

  • The kind is generic for a topic or guide, namespace for a docs section, command or function for a CLI command or API function, schema or enum for a schema or a fixed set of values, component for a component, and page or block for a template.
  • Name a component by its export name and a template by its id. Its link reads as the command that opens it, such as npx @astryxdesign/cli component AcmeCarousel --package @acme/widgets.
  • Inside backticks or a code block, link syntax prints as written.

A link without a provider resolves against your own package. To link the CLI's docs, or another package's, start the target with that package's name, such as @astryxdesign/cli:.

javascript
// Resolves: the CLI's doctor command
{type: 'prose', text: 'Check the app with {@link @astryxdesign/cli:command:doctor}.'},
// Does not resolve: looks for a doctor command in your package
{type: 'prose', text: 'Check the app with {@link command:doctor}.'},
  • Name a CLI command the way you type it, spaces included, such as @astryxdesign/cli:command:doctor integration docs.
  • In a topic that extends another package's topic, your sections still resolve against your package, so a link to the base topic's docs needs its provider.

A link that names no doc prints as written, and doctor integration docs warns. The warning names a search that finds the right target.

bash
npx @astryxdesign/cli doctor integration docs
text
severity: [warn]
code: invalid_doc_graph
message: acme/deploying § check-before-you-ship: "command:doctor" names no doc. Find it with `npx @astryxdesign/cli search doctor --type doc`, then name it as `[<provider>:]<kind>:<name>`.

Replace a topic

Set replaces to take over an existing topic, such as Core's getting-started. Readers who open the old name get your topic.

bash
npx @astryxdesign/cli integration add doc acme-getting-started --replaces getting-started
# The old name now opens your topic
npx @astryxdesign/cli docs getting-started
docs/acme-getting-started.doc.mjs
javascript
export default {
type: 'generic',
name: 'acme-getting-started',
replaces: 'getting-started',
title: 'Acme getting started',
description: 'Install Acme widgets and render your first carousel.',
sections: [/* ... */],
};

The docs list shows your topic in place of the old one. Because your topic has its own name, the old name keeps resolving to it, so links and agents that learned the old name still land on your topic.

Extend a topic

Set extends to merge sections into an existing topic instead of owning it. A section replaces the base section it matches, and a section that matches none is added at the end.

bash
npx @astryxdesign/cli integration add doc acme-theming --extends theme
npx @astryxdesign/cli docs theme --index
docs/acme-theming.doc.mjs
javascript
export default {
type: 'generic',
name: 'acme-theming',
extends: 'theme',
title: 'Acme theming',
description: 'Theme an app that uses Acme widgets.',
sections: [
{id: 'quick-start', title: 'Quick Start', content: [/* replaces the base section */]},
{id: 'use-the-ocean-theme', title: 'Use the ocean theme', content: [/* added at the end */]},
],
};
  • Read the base topic's keys with --index, and give a section that replaces one that key as its id. A section without an id matches only a base section with exactly its title, or one whose id is the key its title makes, so Integration Themes is added beside a base Integration themes that has no id.
  • The topic keeps the base's title and description, and readers open it by the base's name.
  • Replace the Overview placeholder that integration add writes, or it is added to the base topic.
  • Extend a topic to correct or add to it. A copy made with replaces stops getting the owner's fixes.

Check overlaps with Core topics

A topic sets replaces or extends, never both, and a placed guide sets neither. A topic that uses a Core topic's name with neither is an accidental conflict: apps keep reading the Core topic.

bash
npx @astryxdesign/cli doctor integration docs
text
severity: [info]
topic: acme-getting-started
relationship: replaces
coreTopic: getting-started
message: Intentional override: "acme-getting-started" replaces the Core topic "getting-started".
​
severity: [fail]
topic: color
relationship: accidental
coreTopic: color
message: Accidental conflict: "color" is already a Core topic. Rename it, declare replaces: 'color' to take it over, or declare extends: 'color' to merge sections.
  • An intentional overlap prints as [info]. An accidental one fails with exit code 1.
  • A topic that sets both fails as invalid_doc, and so does a placed guide that sets either one.

In global mode

In global mode: a doc that extends or replaces a topic from a package installed for the computer applies in folders outside any project; inside a project, only those from packages the project installs apply. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Keep each read short

Readers open one section at a time, so give each section one idea and keep it to about 30 lines. npx @astryxdesign/cli doctor warns on any read over 32 KB.

  • When a section needs a second idea, split it into two sections.
  • Keep a topic to a few sections. When it grows past five, split it into more guides in your docs section.
  • A topic with more than one section reads as its section list. Readers open one section by its key, or the whole topic with --full.
bash
# The section list
npx @astryxdesign/cli docs acme/deploying
# One section
npx @astryxdesign/cli docs acme/deploying check-before-you-ship
# Everything
npx @astryxdesign/cli docs acme/deploying --full

Lead with the summary

A section's first prose block, or its first list item, is its summary in section lists and search results. Make it answer the section's question in one or two sentences.

  • The summary is cut at about 240 characters.
  • A code block first does not count: the summary comes from the next prose block.
  • Open with the answer, not with background.
text
build-before-you-ship Build before you ship - Build the app, then upload the `dist` folder.

Make docs findable

In a topic with more than one section, search ranks a section whose title matches the query, or that names it in backticks, above words in body text. Title each section with the task a reader searches for.

  • Name the task in the words a reader types, such as "Deploy to production". Avoid titles such as "Overview" or "Details".
  • Write field names, file names, and error codes in backticks, such as deployTarget: in a topic with more than one section, search treats each one as a keyword.
  • Other words in the summary and body match too, but rank below titles and identifiers. The summary shows under each hit, so make it answer the query.

Test a doc the way a new reader finds it: search for the question, and check that the first hit answers it. Quote a query of more than one word.

bash
npx @astryxdesign/cli search "place a doc" --type doc

Run the docs check

Run doctor integration docs in your package to check the docs tree, every link, and overlaps with Core topics, and to list any passage two topics repeat. Pass a package name to check an installed package.

bash
npx @astryxdesign/cli doctor integration docs
# In an app, check an installed package
npx @astryxdesign/cli doctor integration docs @acme/astryx-widgets
text
Checking integration docs: @acme/astryx-widgets@1.0.0
​
[ok] The docs tree and every link in these docs check out.
​
[ok] No doc topics overlap with Core.
[ok] No topic copies a passage from another.
[ok] No filler phrases or low-density topics.

Write each fact in one topic. When two of your topics, or one of yours and a Core topic, repeat 10 or more words of prose word for word, the check lists the pair as a copied_passage warning. It names the topic that keeps the text (a Core topic, or of your two the one the passage fits) and the line in the other where to put the link instead (npx @astryxdesign/cli docs authoring/building-blocks/docs/links). Code and examples are not compared, and a topic may carry the text of the Core topic it replaces. A run with fewer than 4 distinct content words (words other than function words such as "the", "in" or "it"), or one that three or more topics share (a series lead-in), is not listed. Each listed pair is also a warning (copied_passage): doctor integration docs still exits 0, and integration check --strict fails until you fix it.

Keep docs dense. The check lists, as a doc_fluff warning, each padding phrase in your prose, with its file line and what to write instead, and each topic of 80 or more prose words in which fewer than half the words carry content (words other than function words such as the, and, in), with its measured share and the floor of 0.5. doctor integration docs still exits 0, and integration check --strict fails until you cut it. Code, examples, and quoted text are not read. The phrases: it's important to note, it is important to note, important to note that, it's worth noting, it is worth noting, worth noting that, it should be noted, please note, note that (opening a sentence), as mentioned above, as mentioned earlier, as mentioned before, as noted above, as you can see, needless to say, in order to, due to the fact that, for the purpose of, at this point in time, in other words, basically, essentially, obviously.

Point at another doc with a link, never by writing npx @astryxdesign/cli docs <name> in prose: {@link generic:<name>} for one of your package's topics, or {@link @astryxdesign/cli:generic:<name>} for a Core doc (namespace: for a Core namespace). The CLI prints the link as the command that opens the doc, and doctor checks that the doc exists. The check lists each written pointer to a doc that exists as a stray_doc_pointer warning, with the link to write instead. A command with more words, or one in a code block, is a walkthrough and is not listed.

Its arguments and exit codes are in npx @astryxdesign/cli docs cli/commands/doctor-integration-docs.

Know what fails the check

A doc that does not load, an accidental Core overlap, a reference block that names no doc, or a failed namespace or placement fails the check with exit code 1. A link that names no doc only warns, and the exit code stays 0.

ProblemReported asExit code
A doc that does not load, such as an unknown section field or block type[fail] invalid_doc1
A topic with a Core topic's name and no replaces or extends[fail] accidental1
A placement that fails, which hides the doc[fail] invalid_doc_graph1
A reference block whose target names no doc[fail] invalid_doc_reference1
A link that names no doc[warn] invalid_doc_graph0
A topic that sets replaces or extends[info] replaces or extends0

Read the warnings before you publish. With --json, they are in data.issues, with severity: "warning".

Check each doc against its type

doctor integration validate reads every doc your package ships against the type @astryxdesign/cli/authoring publishes for its kind, the way tsc would: a hidden doc and a second doc for one name too. Test fixtures under __tests__ in the components or docs root, which those readers skip, are counted, not checked. It reports a field the type does not have (unknown_field), a missing required field (missing_field), and a value outside the field's type (wrong_type).

bash
npx @astryxdesign/cli doctor integration validate
text
Checking 1 doc(s) against the authoring types (TypeScript 6.0.3)
​
severity: [warn]
doc: components/AcmeCarousel.doc.mjs
line: 7
export: default
type: ComponentDoc
field: summary
problem: unknown_field
message: Object literal may only specify known properties, and 'summary' does not exist in type 'SingleComponentDoc'.
fix: Remove `summary`: SingleComponentDoc has no field `summary`.
  • The folder decides the type: ComponentDoc in your components root, IntegrationTemplateDoc in your templates root, ThemeDoc in your themes root, and in your docs root the type the doc's type names. Keep the @type comment above each doc's export too, so your editor shows the same problems as you type.
  • Every finding is a warning, so the exit code stays 0. With --json, they are in data.docTypes.findings.
  • It uses the typescript your package installs. Where typescript is missing or has no JavaScript compiler API, as TypeScript 7 does, it uses @typescript/typescript6, the TypeScript 6 API package, if your package installs it. Without either, it prints [skip] Docs not type-checked and the reason.
  • Every field of every doc type is in npx @astryxdesign/cli docs authoring/field-reference.

Check read size

npx @astryxdesign/cli doctor also measures every read and warns on one over 32 KB. Run it in your package or in an app that installs it; doctor integration docs does not check size.

bash
npx @astryxdesign/cli doctor
text
id: docs-progressive-disclosure
status: [warn]
label: Documentation navigation and size
message: acme/deploying build-before-you-ship: 46 KB, over the 32 KB one read may return

Split a section over the limit into smaller ones, each with its own key. See npx @astryxdesign/cli docs cli/commands/doctor.

Check the CLI peer

integration verify fails a package that ships a docs section, a placed guide, or a doc section with an id without an @astryxdesign/cli peer of >=0.6.4. It does not run the docs check, so run both.

bash
npx @astryxdesign/cli integration verify
text
- [fail] The package ships a namespace doc or a placed guide but declares no @astryxdesign/cli peer. A stable CLI before 0.6.4 does not read the docs tree, and can hide every doc topic the package ships. Declare "@astryxdesign/cli": ">=0.6.4" in peerDependencies (optional in peerDependenciesMeta, if the CLI is not required).
  • integration add doc --parent writes the peer for you.
  • integration verify passes a hidden guide and an accidental Core overlap; only doctor integration docs catches them.
  • Everything else it checks is in npx @astryxdesign/cli docs cli/commands/integration-verify and npx @astryxdesign/cli docs authoring/ship/checks.

Add a codemod

integration add codemod writes a codemod into a folder named after a version. Apps run it with npx @astryxdesign/cli upgrade to migrate their code.

bash
npx @astryxdesign/cli integration add codemod rename-delay --to 0.7.0
text
codemods/
0.7.0/
rename-delay.mjs # the codemod
rename-delay.test.mjs # skipped: a test file
__tests__/ # skipped: a test folder

The first add declares codemods: './codemods' in astryx.integration.mjs. The codemod's id is its path inside the version folder, without the extension: rename-delay. An id must be unique across all version folders in the package.

The loader skips *.test.*, *.spec.*, and *.fixture.* files and everything under __tests__/ or __fixtures__/, so tests can sit beside the codemod. The folder name decides when an app runs the codemod; see "Choose when a codemod runs".

Write the transform

A codemod default-exports a plain object with a type, a title, and a transform function. transform returns the new source, or null to leave the file as it is.

js
// codemods/0.7.0/rename-delay.mjs
/** @type {import('@astryxdesign/cli/authoring').AstryxCodemod} */
export default {
type: 'code',
title: 'Rename AcmeCarousel delay to interval',
description: 'Renames the delay prop on AcmeCarousel.',
fileExtensions: ['.tsx', '.jsx'],
transform(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
const props = root
.find(j.JSXOpeningElement, {name: {name: 'AcmeCarousel'}})
.find(j.JSXAttribute, {name: {name: 'delay'}});
if (props.size() === 0) return null;
props.forEach(path => {
path.node.name.name = 'interval';
});
return root.toSource();
},
};

type: 'code' rewrites the app's source files that match fileExtensions. type: 'config' rewrites the app's astryx.config file instead, and runs before code codemods. title shows in the upgrade output, and api.jscodeshift is a jscodeshift instance for the file. Every field is in npx @astryxdesign/cli docs authoring codemod.

Choose when a codemod runs

Two rules decide whether an app's npx @astryxdesign/cli upgrade runs your codemods: the app's Core versions, and whether the app names your package.

  1. Version folders are matched against the app's @astryxdesign/core versions, not your package's version. upgrade --from <version> runs each folder above --from, up to and including the Core version installed in the app. With Core 0.7.0 installed, --from 0.6.3 runs 0.7.0/, and --from 0.7.0 runs nothing. A folder named after your own release, such as 1.0.0/, waits until the app has Core 1.0.0.
  2. upgrade runs your codemods only when the app lists your package in integrations in its astryx.config, or passes --integration @acme/astryx-widgets. Having your package installed is not enough: the run then skips your codemods with no warning.

Run codemods in an app

An app previews codemods with npx @astryxdesign/cli upgrade and writes the changes with --apply. Without --apply, nothing on disk changes.

bash
# Preview each codemod and the files it would change
npx @astryxdesign/cli upgrade --from 0.6.3 --integration @acme/astryx-widgets
# Write the changes
npx @astryxdesign/cli upgrade --from 0.6.3 --integration @acme/astryx-widgets --apply
text
Integrations: @acme/astryx-widgets
<n> codemods to run (dry run)
Applying integration codemods...
Rename AcmeCarousel delay to interval (v0.7.0, @acme/astryx-widgets)
! ~ src/Hero.tsx (would change)

<n> counts Core's codemods for the same versions too: they run first and print above Integrations:. To run only yours, as when you test it, add --codemod rename-delay.

--from is the Core version the app had before it upgraded. The run scans ./src unless the app passes --path, and it never writes a file the app marks as generated, vendored, or ignored; see npx @astryxdesign/cli docs cli/commands/upgrade. upgrade --list shows only Core codemods.

An integration manifest has no hooks field. Commands that run after codemods, such as a formatter, are the app's to set, in hooks.postCodemod in its astryx.config.

In global mode

In global mode: inside an app, upgrade runs with the release equal to the app's Core, whichever copy of the CLI the user starts, and only the codemods of integrations the app installs run. Outside a project, upgrade lists codemods and migrates nothing. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs cli/commands/upgrade.

Configuration

In global mode: installed for the computer, beside the global CLI, your package's manifest is read as it is in a project and applies in folders outside any project; peer ranges are checked against the global CLI's release. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Beyond the things you ship, an integration can set how it behaves in an app: the guidance it gives the AI agents working there, the command its users run the CLI with, and the record it keeps of each CLI run. Open one below to set it up.

Add a line of guidance

integration add agent-doc adds one line for AI agents that work in apps using your package. Apps show it in their agent file, such as AGENTS.md.

bash
npx @astryxdesign/cli integration add agent-doc 'Use AcmeCarousel for rotating content.'

The line lands in agentDocs.append in your manifest:

js
// astryx.integration.mjs
export default {
components: './components',
agentDocs: {
append: ['Use AcmeCarousel for rotating content.'],
},
};

Adding the same line again changes nothing. To reword or remove a line, edit agentDocs.append by hand.

Keep within the limits

Every line costs space in each app's agent file, so the CLI keeps guidance short and few. Put detail in your docs instead.

LimitWhat happens past it
8 lines per packageintegration add fails: append may contain at most 8 lines.
240 characters per lineintegration add fails: must contain at most 240 Unicode code points.
32 lines per app, from all its packagesinit writes no agent block and prints Could not install agent docs.

A line is one line of plain text. Line breaks, control characters, spaces at either end, and managed-block markers such as <!-- ASTRYX:START --> are rejected.

Start a command in a line with the word astryx alone, with no runner in front, as in: Run astryx doctor before finishing. The block names the command the app runs once, at its top, for every command below it, so your line works in every app. See npx @astryxdesign/cli docs authoring/building-blocks/configuration/cli-command.

See what agents read

An app's npx @astryxdesign/cli init writes your lines at the end of its managed agent block, each labeled with your package name. The CLI owns the heading, the labels, and the markers.

bash
npx @astryxdesign/cli init --features agents
text
INTEGRATIONS:
- `@acme/astryx-widgets`: Use AcmeCarousel for rotating content.
<!-- ASTRYX:END -->

The block collects lines from every integration the app has installed, with or without an astryx.config entry.

Refresh the block in an app

The block changes only when the app runs init, or upgrade --from <version> --apply. After an app installs a version of your package with new lines, it runs one of them.

bash
npx @astryxdesign/cli init --features agents
# Or: --from takes the Core version the app had before
npx @astryxdesign/cli upgrade --from <version> --apply
text
[ok] Agent docs refreshed -> AGENTS.md

upgrade --apply prints that line; init prints [ok] AI agent docs installed -> AGENTS.md. Without --apply, upgrade only reports Agent docs differ from the installed Astryx and integration configuration. It refreshes the block even when Core did not change. See npx @astryxdesign/cli docs cli/commands/upgrade.

In global mode

In global mode: the agent guide the CLI keeps for the computer carries no integration's guidance, because it serves every folder; your guidance reaches agents through the agent docs init writes in an app that installs your package. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs global/computer-agent-guide.

When to set it

The CLI ends most answers with the next command to run: a hint, an error fix, a Doctor fix, or a doc example. By default each one starts with a command that reaches the release that answered: what the app's package manager runs, such as pnpm exec astryx or yarn astryx, when the app's own CLI answered, and otherwise the CLI package pinned to that release, such as npx @astryxdesign/cli@0.7.2.

Set cliCommand only when your users run the CLI some other way, such as a wrapper your company ships that runs it with your package loaded. Otherwise the commands the CLI prints send them, and their agents, to a CLI that answers without your package. Most packages leave it out.

Set it

Add cliCommand to astryx.integration.mjs: the command, without a subcommand.

js
// astryx.integration.mjs
export default {
components: './components',
cliCommand: 'acme astryx',
};

In an app that installs your package, the CLI then prints every command with it:

text
Error: Theme "ocean" is not added. Run `acme astryx theme add ocean` first.

That covers hints, errors, suggestions, Doctor fixes, --help, the setup reminder, the agent block init writes, and doc text. --json output keeps every command without a runner, as in "command": "astryx theme add ocean", so a script reads the same fields in every app.

How the CLI picks the command

  1. The cliCommand of the packages the app loads, when they name one command.
  2. The command a host tool names when it runs the CLI for the app. Nothing in a plain project names one.
  3. A command that reaches the release that answered: what the app's package manager runs when the app's own CLI answered, such as pnpm exec astryx or yarn astryx; npx @astryxdesign/cli outside a project when the CLI installed for the computer answered; otherwise the CLI package pinned to that release, such as npx @astryxdesign/cli@0.7.2 (see npx @astryxdesign/cli docs global/who-answers).

Two packages that set the same command agree, and the CLI uses it.

When two packages disagree

If two packages an app loads set different commands, the CLI uses neither: it prints commands with what the app's package manager runs, even when a host tool named a command. npx @astryxdesign/cli doctor warns in its cli-command check and names both packages:

text
status: [warn]
label: CLI command
message: Loaded integrations declare different CLI commands: @acme/astryx-widgets (`acme astryx`), @other/kit (`other astryx`). Neither is used, so printed commands start with `pnpm exec astryx`.

The app fixes it by removing the package that does not belong, or the packages agree on one command.

Check it

An invalid value is ignored, and the rest of your manifest still loads. npx @astryxdesign/cli doctor integration validate reports it as invalid_cli_command. A valid cliCommand is one line of plain text without spaces at either end, at most 120 characters.

In an app that installs your package, npx @astryxdesign/cli doctor passes its cli-command check and names your package as the one that set the command. A CLI released before this field reports cliCommand as an unknown key and prints the package manager's command.

bash
npx @astryxdesign/cli doctor integration validate
# In an app that installs the package
npx @astryxdesign/cli doctor

In global mode

In global mode: outside a project, the cliCommand of integrations installed for the computer applies; when none is declared, commands start with the plain command of the CLI installed for the computer. Inside a project, only the project's integrations count, so a cliCommand from a package installed for the computer never changes the project's printed commands. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Record runs with debug

Export a debug function from astryx.integration.mjs; the CLI calls it once per command run in an app loading your package.

js
// astryx.integration.mjs
import {appendFileSync} from 'node:fs';
​
/** @param {import('@astryxdesign/cli/authoring').DebugEvent} event */
export function debug(event) {
if (event.outcome !== 'ok') {
appendFileSync('acme-failed-runs.ndjson', JSON.stringify(event) + '\n');
}
}
​
export default {
components: './components',
};

The event is a DebugEvent with command, outcome, exitCode, durationMs, error, and more, its values scrubbed (redacted: true). Every field is in npx @astryxdesign/cli docs authoring debug-event.

Keep the function synchronous: the CLI calls it as the process exits and never waits for a promise. The app's own debug handler runs first, then yours. A handler that throws is skipped, and the command's output and exit code stay the same.

An app records every command only when its astryx.config names integrations or debug, as listing your package does. Otherwise your handler runs only for commands loading the app's project, such as component and docs, never for --version or a mistyped command. In an app whose config names neither word, those commands also print a stderr warning.

debug is a named export, not a manifest field, so an older CLI ignores it and loads the rest of your manifest.

Turn off debug in an app

An app can refuse every integration's debug handler and keep its own. It sets inheritDebug in its package.json:

json
{"astryx": {"inheritDebug": false}}

Your handler then no longer runs in that app.

Handle gap reports

Export a gapReport handler, and npx @astryxdesign/cli gap-report in an app sends it each gap report, such as a missing component or variant. The handler files the report and returns a receipt.

js
/** @type {import('@astryxdesign/cli/authoring').GapReportHandler} */
export const gapReport = {
audience: 'public',
async handle(report, {signal}) {
if (report.target.package !== '@acme/astryx-widgets') return {status: 'skipped'};
const body = JSON.stringify(report);
const response = await fetch('https://tracker.example.com/issues', {method: 'POST', body, signal});
const {url} = await response.json();
return {status: 'filed', url};
},
};

Every handler in the app gets every report, so check report.target.package and skip reports about other packages.

Every field is in npx @astryxdesign/cli docs authoring gap-report-handler.

The CLI waits 30 seconds, then aborts signal. A throw, a timeout, or an invalid receipt fails your delivery, and the command exits 1; the other handlers still run. Like debug, gapReport is a named export that older CLIs ignore.

Ask before filing in public

Set audience: 'public' when your handler writes somewhere the public can read. The CLI runs it only when the caller passes --confirm-public; an 'internal' handler always runs.

bash
npx @astryxdesign/cli gap-report AcmeCarousel --category missing_variant --reason 'Need a vertical layout'
text
handlerType: integration
handler: @acme/astryx-widgets
audience: public
status: consent_required
message: Rerun with --confirm-public to file this report.

With --confirm-public, the same delivery reads status: filed and shows your url. The report goes to the package named by --package, else the package that owns the component, else Core.

Fall back to issuesUrl

With no gapReport handler in the app, the CLI routes the report to the target package's manifest issuesUrl.

  • A GitHub issues URL, such as https://github.com/acme/widgets/issues, gets an issue filed with the GitHub CLI, gh, once the caller passes --confirm-public.
  • Any other URL comes back as a routed_only receipt for the caller to open.
  • With no issuesUrl, the command fails: Package "@acme/astryx-widgets" provides neither a report handler nor an issues URL.

One handler anywhere in the app, from the app or from any package, turns the fallback off for every report. So does a listed package that fails to load: the CLI cannot tell whether it has a handler, so the report fails for that package instead of going to another tracker. See npx @astryxdesign/cli docs cli/commands/gap-report.

Let the person choose where a report goes

When an app loads packages naming their own issuesUrl, a report can go to the default (the handlers, or the fallback above) or to one of those packages. npx @astryxdesign/cli gap-report --list-categories and the receipt of a report filed without --to list every destination, with a line telling the agent to ask the person.

  • An agent asks the person where to file. It never picks a package destination on its own.
  • --to <package> carries the person's choice: the report goes only to that package's issuesUrl, under the same --confirm-public rule, and no handler runs.
  • Without a choice, leave out --to; the report goes to the default.
  • A --to that names no destination fails before anything is sent and lists the destinations.

In global mode

In global mode: when users install your package for their computer, beside the global CLI, its debug function receives runs in folders outside any project too; inside a project, only if the project installs it. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

First: your peer ranges must include the global CLI's version

Your @astryxdesign/core (and, if you use it, @astryxdesign/cli) peerDependencies ranges must include the version of the CLI installed globally. If they do not, Astryx skips your package outside projects, and npx @astryxdesign/cli doctor warns and names the range.

Inside a project that does not install your package, npx @astryxdesign/cli discover offers it, with the command that adds it to that project, once your peer range admits the project's release.

Where your integration works

  • Installed in an app: it works in that app, as always, whichever npx @astryxdesign/cli the person runs, because the app's version of Astryx answers there.
  • Installed next to the global install (npm i -g @astryxdesign/cli your-package), or in the same folder as the CLI by a tool that installs them together: it works in every folder outside a project. People and agents there see its components, templates, docs, themes, codemods, and discover source.
  • Installed only globally, inside an app that does not install it: it adds nothing there. When it supports the app's version and loads, npx @astryxdesign/cli discover lists it as one the app could add, with the app's install command.
  • Installed both ways: the app's copy is the one used in that app. In your own package folder, your working copy is used.

Astryx loads a global integration with the same loader and checks as an app's, so nothing extra goes in your manifest.

Checklist

  • Declare the @astryxdesign/core (and, if you use it, @astryxdesign/cli) peerDependencies range your package really supports. Outside a project, Astryx skips your package when that range leaves out the global version, and npx @astryxdesign/cli doctor says why: too narrow, and global users lose it after an upgrade; too wide, and it loads against a Core it may not build with.
  • Keep imports in your docs and templates to packages an app installs with yours, since outside a project there is no app to check them against.
  • If you set cliCommand, know that outside a project it starts every command Astryx prints, and that two global integrations naming different commands cancel out, falling back to npx @astryxdesign/cli (npx @astryxdesign/cli docs authoring/building-blocks/configuration/cli-command). Inside an app, only the app's integrations count.
  • Try it globally before you publish, with the steps below.

Try it globally

Pack your package, install it next to a global CLI, and look at it from an empty folder:

bash
npm pack
npm i -g @astryxdesign/cli ./your-package-1.0.0.tgz
mkdir -p /tmp/try-global && cd /tmp/try-global
npx @astryxdesign/cli discover
npx @astryxdesign/cli docs
npx @astryxdesign/cli doctor
  • npx @astryxdesign/cli discover lists your package as installed, with what it adds.
  • npx @astryxdesign/cli docs lists your topics, and npx @astryxdesign/cli search <one of your component names> finds them.
  • npx @astryxdesign/cli doctor's computer-integrations check lists your package as used, with no warning.
  • In an app that does not install it, npx @astryxdesign/cli discover lists it as one the app could add, with the app's install command, and none of its components or docs show up.

Remove it again with npm uninstall -g your-package.

More on global mode

How global mode works for the people who install your package: npx @astryxdesign/cli docs global. Their side of global integrations: npx @astryxdesign/cli docs global/computer-integrations.

Install the packed package in an app

Pack the package beside its folder, so the next pack does not ship it, then install it in an app together with Core and the CLI. The app loads your package because it is a dependency.

bash
# In the package
npm pack --pack-destination ..
​
# In an app folder beside it
npm install @astryxdesign/core @astryxdesign/cli ../acme-astryx-widgets-1.0.0.tgz
  • Install the CLI in the app too: npx @astryxdesign/cli runs the CLI from the app's node_modules.
  • The app holds a copy, not a link. After each change, pack again and run the same npm install.
  • Reading your work inside the package shows your source. The app shows what npm would publish.

Check what the app sees

From the app folder, run the commands your users run. Each should show your contribution under your package name.

bash
npx @astryxdesign/cli component AcmeCarousel
npx @astryxdesign/cli template --list --package @acme/astryx-widgets
npx @astryxdesign/cli theme list --package @acme/astryx-widgets
npx @astryxdesign/cli docs acme
npx @astryxdesign/cli search deploying --type doc
CommandLook for
component AcmeCarouselimport {AcmeCarousel} from '@acme/astryx-widgets/components/AcmeCarousel';
template --list --packageAn acme-dashboard entry whose package is @acme/astryx-widgets
theme list --packageAn ocean entry whose package is @acme/astryx-widgets
docs acmeYour section, with guides such as deploying
search deploying --type docacme/deploying as the first result

--package narrows template --list and theme list to one package. component --list ignores it and lists Core too, so look a component up by name. Last, import AcmeCarousel in the app's code and build the app, as your users will.

Run doctor in the app

npx @astryxdesign/cli doctor checks the whole app, including every package it loaded. doctor integration validate checks your package as the app installed it.

bash
npx @astryxdesign/cli doctor
npx @astryxdesign/cli doctor integration validate @acme/astryx-widgets
text
id: implicit-integrations
status: [info]
label: Implicitly linked integrations
message: 1 integration loaded from installed dependencies with no astryx.config entry: @acme/astryx-widgets@1.0.0 from dependencies, contributing components, templates, themes, docs, codemods.
  • The implicit-integrations row names each package the app loads with no config. [info] is expected.
  • The docs-progressive-disclosure row warns when one of your doc sections is over 32 KB. The doctor integration checks do not measure size.
  • doctor integration validate @acme/astryx-widgets prints [ok] No integration issues found. when the installed copy is sound.

Pick a check

Run these in the package folder. The four doctor integration checks read your source, and integration check runs all four; integration verify checks the package npm would publish.

CommandProvesExits 1 whenOnly warns when
npx @astryxdesign/cli doctor integration validateThe manifest loads, each root holds contributions the CLI can read, and each doc matches its authoring type. Notes, which fail nothing, list a malformed cliCommand and each command a topic writes with a package runner (runner_command)A declared root is missing (missing_root), a contribution does not load (invalid_doc, invalid_component, invalid_theme), or two templates in the package replace one Core id (ambiguous_template_replacement)The manifest has a key this CLI does not know (unknown_manifest_key), or a doc field does not match the doc's authoring type: a field the type does not have, a missing required field, or a value outside the field's type. It also warns on what no reader can serve: a prop listed twice (duplicate_prop), a propDescriptions key that names no documented prop (unknown_translated_prop), a section with no content (empty_section), two sections of a topic with one key (duplicate_section_key), or a .doc.zh.mjs or .doc.dense.mjs overlay beside no topic file (orphan_doc_overlay). Since 0.7.3 it also lists, from source and without failing, what the package tells apps to import that they cannot: a component's import (the doc's import, or the one the CLI derives) or a template's public import whose subpath package.json exports does not declare (component_import_not_exported, template_import_not_exported) or that names another package (component_import_other_package); an example that imports an undeclared subpath (example_import_not_exported); a template that imports a path outside the templates root (template_import_outside_root) or a path its package or Core does not export (template_private_import). It lists the same way a component's import whose module does not export the component (component_import_missing_name), an example or a template that imports a name its package does not export (example_import_missing_name, template_import_missing_name), and a template source with no default export (template_no_default_export). Each is a note, printed with its fix, that fails --strict from the next minor; an import it cannot judge, such as an exports target not built yet or a deep path into a dist/ not built yet, is listed under publicImports as not checked. It checks docs with your installed typescript, or, where that is missing or has no JavaScript compiler API (TypeScript 7), with an installed @typescript/typescript6; without either, it prints [skip] Docs not type-checked. With no astryx.integration.mjs it prints a hint and exits 0
npx @astryxdesign/cli doctor integration componentsNo component name clashes with a Core component, each replaces names one Core component, and each component doc's props match the component's TypeScript propsCore is not installed (core_not_found). In a package that declares the CLI range that turns replacement on, a replaces target is missing (missing_component_replacement_target) or invalid (invalid_component_replacement), or two components replace one Core component (ambiguous_component_replacement)A name clashes with Core, a component sets replaces in a package without that range (inactive_component_replacement, plus any missing, invalid, or duplicate replaces reported as a warning), or a documented prop differs from the code: it is missing there (not_in_code), the doc omits a prop the package declares (not_in_doc), or its required (required_mismatch), type (type_mismatch), or literal default (default_mismatch) disagrees, or a {@link} in a component doc names no doc (invalid_doc_graph). It reads props with your installed typescript; without it, it prints [skip] Props not compared
npx @astryxdesign/cli doctor integration templatesEach replaces names a Core template of the same typeA replaces target is missing (missing_template_replacement_target) or of the other type (invalid_template_replacement), or two templates replace one id (ambiguous_template_replacement)A template id matches a Core id without replaces
npx @astryxdesign/cli doctor integration docsYour docs tree, every link, topic names against Core, passages two topics repeat word for word, padding phrases and low-density topics, and written pointers to docsA topic takes a Core topic name without replaces or extends, a doc is invalid (invalid_doc), or a namespace or placement fails, which hides the doc (invalid_doc_graph)A link names no doc (invalid_doc_graph), two topics repeat a passage word for word (copied_passage), prose pads with a listed phrase or falls under the density floor (doc_fluff), or prose, a description or summary, or the text of a component doc writes npx @astryxdesign/cli docs <name> where a link would be checked (stray_doc_pointer)
npx @astryxdesign/cli integration checkEverything the four doctor integration checks prove, in one command, for your buildAny of the four would exit 1 on its own. With --strict, also any warning, and any check that did not run, such as a package with no astryx.integration.*Anything the four warn about, unless you pass --strict
npx @astryxdesign/cli integration verifyThe packed package holds every file, shows the same contributions, resolves every public import, and declares the CLI it needsAnything validate fails on, no manifest, a file left out of the .tgz file, an import that does not resolve, or a missing CLI peerAnything validate warns about, or a component sets replaces and the package does not declare the CLI range that turns it on (component_replaces_needs_cli)

Pass a package name, such as npx @astryxdesign/cli doctor integration validate @acme/astryx-widgets, to check an installed copy from an app instead. To fix a props finding, see npx @astryxdesign/cli docs authoring/building-blocks/components/describe-the-component/check-the-props.

Run every check in every build (required)

Every integration runs integration check --strict in its own build, so every build keeps your docs, types, and links in sync; this is required, not optional. The strict check fails on every copied passage the docs check lists (npx @astryxdesign/cli docs authoring/building-blocks/docs/check-your-docs). Run integration verify before you publish. Install devDependencies first, because the components check needs Core, and tsc --noEmit needs typescript and a tsconfig.json. In package.json:

json
"typecheck": "tsc --noEmit && astryx integration check --strict"
  • integration check runs validate, components, templates, and docs in that order, prints each result, then a summary, and exits 1 when any of them would.
  • --strict also exits 1 on any warning and on any check that did not run. Without it, warnings keep exit code 0: use that while you fix the warnings a package already has.
  • Its exit codes and JSON are a contract for your build: a new check, or a new failure code, joins only in a minor release that names it in the release notes. A patch can still ship a Core component, template, or CLI topic whose name conflicts with one of yours.
  • integration verify runs validate but none of the other three: a Core name clash, a replaces that names no Core template, a topic that takes a Core name, or a hidden guide still passes it. Run both before you publish: npx @astryxdesign/cli integration check --strict && astryx integration verify.
  • Bare npx @astryxdesign/cli doctor in the package also warns about a doc section over 32 KB, which no check above measures.

Verify the packed package

integration verify packs your package with npm, unpacks it into a temporary app, and checks that the app sees everything your source has. It publishes nothing and leaves no .tgz file or temporary folder behind.

  1. It runs npm pack the way npm publish would, including your prepack script.
  2. It checks that every contribution file is in the .tgz file. A root missing from files fails with Add "templates" to "files" in package.json.
  3. It lists the components, templates, themes, docs, and codemods the temporary app sees, and compares them with your source.
  4. It resolves each component's import, and each template's public import, the way Node does, and checks that the module exports the component under the name its doc documents, or a default export for a template. A type-only export does not count.
  5. For each theme, it rebuilds the source, compares the built module and production CSS with the authored outputs, and proves the module, CSS, and optional font CSS exports resolve from the packed package.
  6. It fails a package that needs an @astryxdesign/cli peer and lacks it: >=0.6.6 for a template that sets keywords, and >=0.6.4 for a template that sets replaces, a docs section, a placed guide, a doc section with an id, or a theme.

integration pack --check, the name this check had before published 0.6.5, still runs it and prints a note; it will be removed in a later release. The options and exit codes are in npx @astryxdesign/cli docs cli/commands/integration-verify.

Pick a version

Version your package with semver: a major version for a breaking change, a minor version for a new feature, and a patch for a fix.

bash
# 1.0.0 -> 2.0.0: a breaking change
npm version major
# 1.0.0 -> 1.1.0: a new feature
npm version minor
# 1.0.0 -> 1.0.1: a fix
npm version patch
  • Before 1.0.0, npm treats each minor version as breaking: an app that asks for ^0.6.0 never gets 0.7.0. Bump the minor version for a breaking change, and the patch for anything else.
  • Ship a codemod with each breaking change so apps can migrate; see npx @astryxdesign/cli docs authoring/building-blocks/codemods.

Know what breaks apps

A breaking change stops code, commands, or links that worked in an app from working after the upgrade. For an integration, these names are part of its API.

You change or removeWhat stops working in the app
A component name, such as AcmeCarouselImports, and npx @astryxdesign/cli component AcmeCarousel
A template id, such as acme-dashboardnpx @astryxdesign/cli template acme-dashboard
A theme slug, such as oceannpx @astryxdesign/cli theme add ocean
A topic name or route, such as acme/deployingReads of the old name, and links to it from other docs
A component's import pathImports written from the old path
An exports entryImports of that path, which fail with ERR_PACKAGE_PATH_NOT_EXPORTED
A propCode that passes it

List each breaking change in your release notes, with the codemod that migrates it. Any changelog tool works. If package.json has a files allowlist, add CHANGELOG.md to it so npm packs it.

Declare peer ranges

Declare the Astryx packages that your code imports as peer dependencies, in peerDependencies, so the app installs one copy of each. Keep each range as wide as your tests prove.

PeerDeclare it whenRange
@astryxdesign/coreYour code imports Core, as a theme does with @astryxdesign/core/themeThe Core versions you test, such as ^0.6.0
@astryxdesign/theme-*Your code imports that theme packageThe versions you test
@astryxdesign/cliYou ship a docs section, a placed guide, a template that sets replaces or keywords, a component that sets replaces, a doc section with an id, or a theme>=0.6.6 when you ship a template that sets keywords, >=0.6.7 when a component sets replaces, otherwise >=0.6.4. Optional in peerDependenciesMeta
json
{
"peerDependencies": {
"@astryxdesign/core": "^0.6.0",
"@astryxdesign/cli": ">=0.6.4"
},
"peerDependenciesMeta": {
"@astryxdesign/cli": {"optional": true}
}
}

integration verify fails a package that needs the CLI peer and lacks it, or whose range admits a stable CLI too old for what it ships. integration add doc --parent and integration add theme write the peer for you.

When the CLI is outside that range, doctor integration validate and integration check list a cli_peer_version note naming your package. A note fails nothing, --strict included, and integration verify does not report it. Below the range the note gives the commands that install a newer Core and CLI together, as dev dependencies in your own package folder, and then run upgrade --apply. Above it, in an app the note asks for a release of your package that supports that CLI, and in your own package folder it asks you to test your package with that CLI and widen the range.

Support older CLIs

An app may run an older CLI than the one you build with. A CLI reads what it knows and skips the rest, but some newer files make an older CLI hide your docs.

  • An unknown field in astryx.integration.mjs is ignored with an unknown_manifest_key warning, and the rest of the manifest still loads.
  • A named export that the CLI does not know is ignored with no warning, so debug and gapReport are safe to add.
  • Published 0.6.3 and earlier print each {@link ...} as written.
  • Published 0.6.3 and earlier reject a template that sets replaces or keywords, drop that template, and hide every doc topic your package ships. Published 0.6.4 and 0.6.5 still drop a template that sets keywords.
  • Published 0.6.3 and earlier cannot read a docs section, a section id, or a theme folder that integration add theme writes. They can then hide every doc topic your package ships.
  • Published 0.6.2 and 0.6.3 still load your components, but published 0.6.0 and 0.6.1 cannot read the component docs that integration add component writes: component AcmeCarousel fails there.
  • An app on an older CLI lists a component whose doc names another export of its file under the file name, and lists no family members, so component AcmeCarousel fails there.

integration verify requires the CLI peer for a docs section, a placed guide, a template replaces or keywords, a doc section with an id, and a theme. A CLI too old for one of them cannot read it, and when it hides your topics, docs gives no warning.

Name codemod folders after Core versions

Name each codemod folder after the Core version whose upgrade should run it, not after your package's version. Which folders an app runs, and when, is in "Choose when a codemod runs" in npx @astryxdesign/cli docs authoring/building-blocks/codemods.

Check before you publish

Publish only a package that passes every check, then preview the upload with npm publish --dry-run. The dry run lists the files and the tag without uploading.

bash
npx @astryxdesign/cli doctor integration validate && npx @astryxdesign/cli doctor integration components && npx @astryxdesign/cli doctor integration templates && npx @astryxdesign/cli doctor integration docs && npx @astryxdesign/cli integration verify
npm publish --dry-run --access public
text
npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access (dry-run)
+ @acme/astryx-widgets@1.0.0

Set a new version for each release; npx @astryxdesign/cli docs authoring/ship/versioning covers what counts as breaking. What each check proves is in npx @astryxdesign/cli docs authoring/ship/checks.

Keep private files out

With no files list, npm packs every file your ignore files do not exclude, including an old .tgz file or an .env file. List what ships instead.

bash
npm pkg set 'files[]=components' 'files[]=templates' 'files[]=themes' 'files[]=docs' 'files[]=codemods' 'files[]=astryx.integration.mjs'
npm pack --dry-run
  • List only the roots you ship, and always astryx.integration.mjs. npm adds package.json, and a README and LICENSE when you have them.
  • Each later integration add appends its root to the list.
  • integration verify fails when a root is missing from the list, and names the root to add to files (the message is in npx @astryxdesign/cli docs authoring/help/troubleshooting).

Publish to npm

A scoped package is private by default, so publish it with --access public. Without --tag, npm tags the release latest, which is what npm install @acme/astryx-widgets installs.

bash
# Publish a release
npm publish --access public
​
# Or set the access once in package.json
npm pkg set publishConfig.access=public

To let apps try a release first, publish it under next, then point latest at it once ready.

bash
# Only apps that ask for next get it: npm install @acme/astryx-widgets@next
npm publish --tag next
​
# Make that version the default
npm dist-tag add @acme/astryx-widgets@1.1.0 latest
npm dist-tag ls @acme/astryx-widgets

Check the published package

Install the version you published in a clean app, and run the same commands you ran against the .tgz file.

bash
mkdir check-release && cd check-release
npm init -y
npm install @astryxdesign/core @astryxdesign/cli @acme/astryx-widgets@1.0.0
npx @astryxdesign/cli component AcmeCarousel
npx @astryxdesign/cli doctor integration validate @acme/astryx-widgets

The full list of commands to run in an app is in npx @astryxdesign/cli docs authoring/ship/test-in-an-app. To help apps move to the new version, see npx @astryxdesign/cli docs authoring/ship/upgrading.

Update for a new Astryx release

When Astryx ships a release, bump your devDependencies, rerun the checks, widen your peer ranges, and ship a codemod for each change that breaks apps.

bash
# 1. Build and test against the new release
npm install -D @astryxdesign/cli@latest @astryxdesign/core@latest
# 2. Rerun the checks
npx @astryxdesign/cli doctor integration validate
npx @astryxdesign/cli doctor integration docs
npx @astryxdesign/cli integration verify
# 3. Admit the new Core once the checks pass
npm pkg set 'peerDependencies.@astryxdesign/core=^0.6.0 || ^0.7.0'
# 4. Migrate apps across a change that breaks them
npx @astryxdesign/cli integration add codemod rename-delay --to 0.7.0

Upgrade an app

After an app installs a new Core, npx @astryxdesign/cli upgrade runs the codemods for the versions it crossed. It is a dry run by default; --apply writes the changes.

bash
# Preview what would change; nothing is written
npx @astryxdesign/cli upgrade --from 0.6.3 --integration @acme/astryx-widgets
# Write the changes
npx @astryxdesign/cli upgrade --from 0.6.3 --integration @acme/astryx-widgets --apply
text
Integrations: @acme/astryx-widgets
<n> codemods to run
Applying integration codemods...
Rename AcmeCarousel delay to interval (v0.7.0, @acme/astryx-widgets)
[ok] [ok] src/Hero.tsx
  • --from is the @astryxdesign/core version the app had before, and the target is the installed Core.
  • --integration names your package. Without it, or an integrations entry in astryx.config, the upgrade skips your codemods, even when the app has your package installed.
  • <n> includes Core's codemods for the same versions, which run first.
  • Put this command in your release notes.

Know which files upgrades skip

Upgrades never write files that the app marks as generated, vendored, or ignored. When such a file still needs a codemod change, the upgrade stops with ERR_CODEMOD_PROTECTED and exit code 1.

  • Generated: an @generated comment at the top of the file, or linguist-generated in .gitattributes.
  • Vendored: linguist-vendored in .gitattributes.
  • Ignored: a match in .gitignore or .hgignore.
text
! ! src/gen/Gen.tsx - protected by @generated in the leading comment block
ERR_CODEMOD_PROTECTED: 1 protected file still requires a codemod change:
src/gen/Gen.tsx — @generated in the leading comment block
Upgrade incomplete: protected changes remain

The upgrade still writes every other file. Its options and exit codes are in npx @astryxdesign/cli docs cli/commands/upgrade.

Fix setup errors

These appear while you start the package or run the CLI in it. Each row quotes what the CLI prints.

MessageFix
No package.json found. Run this command inside an integration package.Run npm init -y and npm pkg set name=@acme/astryx-widgets first, in the package folder.
Error: Could not find @astryxdesign/core packageComponent commands read Core. Run npm install -D @astryxdesign/core in the package.
core_not_found: Could not resolve @astryxdesign/core, so component names could not be checked.Same fix. The check exits 1.
Error: unknown subcommand 'integration verify', or Error: Pass --check to verify the integration tarball.Your CLI is older than integration verify. Run npm install -D @astryxdesign/cli@latest. On the older CLI, the same check is integration pack --check.
Note: integration pack --check is deprecated.The old name still runs the same check. Switch to npx @astryxdesign/cli integration verify; the old name will be removed in a later release.

Fix integration verify failures

npx @astryxdesign/cli integration verify prints each failure as a [fail] line. Add --json to see its issue code, such as component_export_missing.

MessageFix
component_export_missing: Component "AcmeCarousel" advertises import "@acme/astryx-widgets/components/AcmeCarousel", but that packed module does not export "AcmeCarousel".package.json has no exports map, or the module does not export the name. Keep export function AcmeCarousel, and add the entry: npm pkg set 'exports[./components/AcmeCarousel]=./components/AcmeCarousel.tsx'. When the quoted name is a file name, such as acme-carousel, set the doc's name to the component the file exports. A type-only export does not count.
template_export_missing: Template "acme-dashboard" public import "@acme/astryx-widgets/templates/acme-dashboard" does not have a default export.Run npm pkg set 'exports[./templates/acme-dashboard]=./templates/acme-dashboard.tsx', and keep export default in the source. Start new packages with "exports": {} so each add writes these.
component_import_unresolvable or template_import_unresolvable: …Package subpath './components/AcmeCarousel' is not defined by "exports"…The exports map has no entry for it. Run npm pkg set 'exports[./components/AcmeCarousel]=./components/AcmeCarousel.tsx', or 'exports[./templates/acme-dashboard]=./templates/acme-dashboard.tsx' for a template.
component_import_unresolvable: …but a consumer cannot resolve it: Cannot find package '@acme/old-name'You renamed the package after the add wrote each doc's import. Change import in every component doc to the new name.
Declared templates root "templates" has 0 of 2 expected files in the pack list. Add "templates" to "files" in package.json.Run npm pkg set 'files[]=templates'.
docs_tree_needs_cli: The package ships a namespace doc or a placed guide but declares no @astryxdesign/cli peer.Run npm pkg set 'peerDependencies.@astryxdesign/cli=>=0.6.4' and npm pkg set 'peerDependenciesMeta.@astryxdesign/cli.optional=true' --json.
replaces_needs_cli: The package has a template that sets replaces but declares no @astryxdesign/cli peer.Run npm pkg set 'peerDependencies.@astryxdesign/cli=>=0.6.4' and npm pkg set 'peerDependenciesMeta.@astryxdesign/cli.optional=true' --json. A stable CLI before 0.6.4 rejects replaces, drops that template, and hides your doc topics.
component_replaces_needs_cli (a warning): The package has a component that sets replaces but declares no @astryxdesign/cli peer, or a peer range that admits an earlier CLI.Run npm pkg set 'peerDependencies.@astryxdesign/cli=>=0.6.7' and npm pkg set 'peerDependenciesMeta.@astryxdesign/cli.optional=true' --json to turn the replacement on. Until then apps see no change (npx @astryxdesign/cli docs authoring/building-blocks/components/replace-a-core-component).
keywords_needs_cli: The package has a template that sets keywords but declares no @astryxdesign/cli peer.Run npm pkg set 'peerDependencies.@astryxdesign/cli=>=0.6.6' and npm pkg set 'peerDependenciesMeta.@astryxdesign/cli.optional=true' --json. What an older CLI does with the field is in npx @astryxdesign/cli docs authoring/building-blocks/templates/document-the-template/template-doc-overview.
themes_need_cli: The package ships a theme but declares no @astryxdesign/cli peer.Run npm pkg set 'peerDependencies.@astryxdesign/cli=>=0.6.4' and npm pkg set 'peerDependenciesMeta.@astryxdesign/cli.optional=true' --json. A CLI older than published 0.6.4 cannot read typed theme descriptors, so it drops your themes and can hide your doc topics.
section_ids_need_cli: The package has a doc section that sets id but declares no @astryxdesign/cli peer.The same fix as themes_need_cli, or drop the section ids: a CLI older than published 0.6.4 rejects them and hides your doc topics.

Fix problems in an app

These appear in an app that installs your package, or in the doctor checks that predict them.

SymptomFix
Error: No component named "AcmeCarousel" for a component your package documents.Its doc still names its file. Set name to the export, such as name: 'AcmeCarousel' in acme-carousel.doc.mjs. A family member is listed only when the module its family doc imports exports it (npx @astryxdesign/cli docs authoring/building-blocks/components/describe-the-component/component-family).
npx @astryxdesign/cli component Dialog opens Core's Dialog, not yours.A name that Core, a legacy docs package, or another package's file already has stays theirs. Run npx @astryxdesign/cli component Dialog --package @acme/astryx-widgets, or give your component a name of its own.
Error: Template "dashboard" is ambiguous — narrow it with --type and/or --package.Your template id matches a Core id. Rename it, such as acme-dashboard, or set replaces: 'dashboard' to take its place. npx @astryxdesign/cli doctor integration templates warns about this before you publish.
invalid_doc_graph: placement.parent "namespace:nope" names no namespace; @acme/astryx-widgets declares "acme".The guide stays hidden until its placement names a namespace and slot your package declares. For "…" names no doc, fix the link target.
Error: Unknown topic "acme". The docs of @acme/astryx-widgets did not load; run npx @astryxdesign/cli doctor integration docs in that package to see why.One invalid doc hides all of your docs. Run npx @astryxdesign/cli doctor integration docs in the package and fix the invalid_doc it names.
cli-command warns: Loaded integrations declare different CLI commandsTwo packages the app loads set different cliCommand values, so the CLI prints the package manager's command. Agree on one command, or remove the package that does not belong. See npx @astryxdesign/cli docs authoring/building-blocks/configuration/cli-command.
invalid_cli_commandYour cliCommand is not one line of plain text of at most 120 characters, so it is ignored. Fix the value; the rest of the manifest still loads.
npx @astryxdesign/cli upgrade runs none of your codemodsThe app must list your package in integrations in astryx.config, or pass --integration @acme/astryx-widgets. It runs a codemod folder only when its version is after --from and at most the installed @astryxdesign/core.
Your docs section, templates, or themes are missing only in some appsThose apps run a CLI too old to read them (npx @astryxdesign/cli docs authoring/ship/checks lists which field needs which CLI). Update @astryxdesign/cli there, and keep your optional @astryxdesign/cli peer so npm checks the app's CLI against it when the app installs your package.

What each check catches is in npx @astryxdesign/cli docs authoring/ship/checks.

Astryx Integration

The astryx.integration.* manifest that sits beside an integration package's package.json. It can preserve a stable provider identity across a package rename, and points the CLI at the package's components, templates, codemods, doc topics, source themes, managed agent guidance, issue tracker, and the command its users run the CLI with. Every field is optional.

Applies to: astryx.integration.{ts,mjs,js}

FieldTypeRequiredDescription
providerIdstringnoThe name that marks this package as the source of everything it contributes. Leave it out to use the package name from package.json. Set it to the old name only during a rename, so the IDs of what the package already contributed stay the same. If two packages use the same name here, the one you are working on wins; otherwise the one the CLI reads first wins, and the CLI warns about the other. Example: '@acme/widgets'.
componentsstringnoThe folder that holds your components and their docs, relative to package.json. Example: './src/components'.
templatesstringnoThe folder that holds your templates, relative to package.json. Example: './src/templates'.
codemodsstringnoThe folder that holds your codemods, relative to package.json. Example: './codemods'.
docsstringnoThe folder that holds your doc topics, relative to package.json. Every {topic}.doc.{ts,mjs,js} under it shows up in npx @astryxdesign/cli docs: a flat topic next to the built-in topics, where it can set replaces or extends to take over a built-in topic or add to it, and a guide with placement at its own route in the docs tree, under a namespace doc of the same package. Example: './docs'.
themesstringnoThe folder that holds your themes, relative to package.json, with one folder per theme. Each theme folder has the theme source and a matching .doc.mjs file with the same name. Installed themes show up in npx @astryxdesign/cli theme list; theme add imports their built package exports, and theme eject creates an editable local fork. Example: './themes'.
agentDocs{ append?: readonly string[] }noLines of guidance your package adds to the end of the agent instructions the CLI manages. The CLI owns the heading, labels, bullets, and which files it writes. Example: { append: ['Run acme verify.'] }.
issuesUrlstringnoWhere people file issues for this package: an absolute URL, such as its GitHub issues page. A value that is not a URL fails the whole manifest; the scheme is not checked. Nothing warns when it is missing, but without it npx @astryxdesign/cli gap-report about this package fails unless the project or a loaded integration has a gapReport handler, and npx @astryxdesign/cli swizzle prints no feedback link for this package's components. When no handler is loaded, a github.com issues URL is filed with the GitHub CLI once the reporter passes --confirm-public; any other URL is returned for the reporter to open. Example: 'https://github.com/acme/widgets/issues'.
docsiteUrlstringnoLink to this integration's docs site, such as its home page. Catalogs that list the package may show it. Example: 'https://widgets.acme.dev'.
cliCommandstringnoThe command your users run the Astryx CLI with, when it is not their package manager, such as a wrapper your company ships. Every command the CLI prints in an app that loads your package starts with it, in place of pnpm exec astryx or yarn astryx: hints, errors, Doctor fixes, and doc text. One line, without a subcommand. If two loaded packages set different commands, neither is used and npx @astryxdesign/cli doctor warns. An invalid value is ignored with an invalid_cli_command warning, and the rest of the manifest still loads. JSON output keeps commands bare. Example: 'acme astryx'.
Typical
js
export default {
components: './src/components',
templates: './src/templates',
codemods: './codemods',
docs: './docs',
themes: './themes',
agentDocs: {
append: ['Run acme verify before finishing.'],
},
issuesUrl: 'https://github.com/acme/widgets/issues',
};

In global mode: a manifest loads the same way when users install your package for their computer, beside the global CLI, and then applies in folders outside any project; inside a project, only if the project installs it. Peer ranges for @astryxdesign/core and @astryxdesign/cli are checked against the global CLI's release. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

The provider name defaults to the package name in package.json. During a rename, set providerId to the old package name so the IDs of what the package already contributed stay the same. The package version always comes from package.json.

agentDocs.append may contain at most eight lines. Each line is a trimmed, non-blank string of at most 240 Unicode code points with no line separators, control characters, NUL, or Astryx/XDS managed-marker text. The configured project may contain at most 32 integration lines total. Write each command in a line as a bare npx @astryxdesign/cli command; the managed block says once, before your lines, how the app starts the CLI.

Set cliCommand only when your users run the CLI with a command other than their package manager's, such as acme astryx. The CLI picks the command it prints in this order: the cliCommand of the loaded integrations when they name one command, then the command a host tool names, then the package manager's. Two different cliCommand values cancel out, and npx @astryxdesign/cli doctor warns in its cli-command check. See npx @astryxdesign/cli docs authoring/building-blocks/configuration/cli-command.

A themes root is version-gated: an older CLI lists and adds none of its themes. Depending on its age, that CLI ignores themes with an unknown_manifest_key warning and loads every other contribution kind, reports the root as invalid because it expects the earlier manifest.json theme catalog, or, if it is old enough to treat an unknown field as an error, rejects the whole manifest, so the package contributes nothing there.

Validate the manifest with npx @astryxdesign/cli doctor integration validate. At the load boundary, a known field of the wrong type is an error, issuesUrl and docsiteUrl must be valid URLs, and unknown fields become warnings so an older CLI can still load the fields it understands. Before publishing, also run templates, components, and docs under the same doctor integration group. Those leaves compare authored identities with Core and explain whether an overlap is intentional or needs a rename.

astryx.config

The optional astryx.config.* file at your project root. Declares which integrations to load, where to route issue links, post-codemod hooks, local debug-log and gap-report handlers, a discover source, and experimental layout components. All optional; {} is valid.

Applies to: astryx.config.{ts,mjs,js}

FieldTypeRequiredDescription
integrationsstring[]noPackage names of Astryx integrations to load alongside core. Example: ['@acme/astryx-widgets'].
issuesUrlstringnoWhere reports about Core go: npx @astryxdesign/cli gap-report routes a Core report here when no handler is loaded, gapReport handlers receive it as report.target.issuesUrl, and npx @astryxdesign/cli swizzle prints it as the feedback link for a Core component. A report or swizzle for an integration's component uses that integration's own issuesUrl, never this one. An absolute URL; a value that is not a URL makes the config invalid. Default: 'https://github.com/facebook/astryx/issues/new'.
hooks{ postCodemod?: PostCodemodHook[] }noLifecycle hooks.
hooks.postCodemodPostCodemodHook[]noCommands to run after npx @astryxdesign/cli upgrade changes files (e.g. re-run your formatter). Each hook is an object {name?, buildCommand}: buildCommand gets {packageDir, files} and returns {command, args?, options?} to run, or null to skip. A dry run prints each command instead of running it.
debug(event: DebugEvent) => voidnoRecord every command run. The handler is synchronous; promises are not awaited and output goes to stderr. Declare debug directly in this file so early commands can discover it. An integration can supply one too, as a debug named export from its manifest. Every handler runs: yours first, then each integration's in load order (the integrations list, then autolinked ones). A handler that throws is skipped; the others still run and the command's result does not change. Set {"astryx": {"inheritDebug": false}} in package.json to take only your own. Example: event => appendFileSync('runs.ndjson', JSON.stringify(event) + '\n').
gapReportGapReportHandlernoHandle explicit gap reports in addition to every loaded integration handler. The project handler runs first. Public handlers require caller consent; internal handlers always run. Example: { audience: 'internal', async handle(report, {signal}) { return sendGap(report, {signal}); } }.
discoverDiscoverSourcenoTell npx @astryxdesign/cli discover which integrations this project could add: an async function that returns a catalog. An integration can provide one too, as a discover named export from its manifest. Discover calls every source, yours first, and one that fails never hides the others. Discover only reads; your package manager installs. Example: async ({signal, package: name, version}) => fetchCatalog({signal, name, version}).
experimental{ xle?: { components?: Record<string, XleComponent> } }noUnstable features; may change without a breaking bump.
experimental.xle.componentsRecord<string, XleComponent>noCustom components for the removed layout expander, keyed by component name. The CLI still validates the field, so an entry without a string from makes astryx.config invalid, but nothing reads it.
Minimal
js
export default {
integrations: ['@acme/astryx-widgets'],
};
Send every command run somewhere of your own
js
import {appendFileSync} from 'node:fs';
​
export default {
debug: event => appendFileSync("runs.ndjson", JSON.stringify(event) + "\n"),
};

The config is validated at load with a strict schema: an unknown key, a misspelled one included, or a value of the wrong type makes the whole file invalid. npx @astryxdesign/cli gap-report, npx @astryxdesign/cli discover, and npx @astryxdesign/cli upgrade stop with the error, and npx @astryxdesign/cli doctor fails its config check (exit 1). Reads such as component, docs, search, template, and theme carry on silently as if there were no config and no integrations, and no debug handler runs, so run npx @astryxdesign/cli doctor after editing the file.

DebugEvent

One recorded CLI run: what a debug handler receives, whether it is set in astryx.config or exported by an integration manifest. There is exactly one event per run, and every field is present by the time a handler sees it.

Applies to: The argument of a debug handler

FieldTypeRequiredDescription
schemaVersionDebugSchemaVersionyesVersion of the recorded shape: 1, 2, or 3. A field that changes meaning gets a new version, so switch on this.
idstringyesUnique per run. Deduplicate on it.
startedAtstringyesWhen the run started, as ISO 8601.
endedAtstringyesWhen the run ended, as ISO 8601.
durationMsnumberyesHow long the run took, in milliseconds.
commandstringyesThe full command name. Empty when astryx ran without a command. Example: 'theme build'.
commandPathstring[]yesThe command name split into segments. Example: ['theme', 'build'].
argvstring[]yesThe arguments after the binary, scrubbed.
argsRecord<string, unknown>yesPositional arguments, keyed by their declared names.
optionsRecord<string, unknown>yesThe command options, as the CLI resolved them.
optionSourcesRecord<string, DebugOptionSource>yesWhere each option value came from: 'cli', 'default', 'env', 'config', or 'implied'. The keys match options.
globalOptionsRecord<string, unknown>yesThe root flags, such as --json, --detail, and --lang.
outcomeDebugOutcomeyesHow the run ended: 'ok', 'error', 'parse-error', 'fatal', 'rejected', or 'incomplete'.
exitCodenumberyesThe process exit code. When a signal ended the run, 128 plus the signal number.
signalstringyesThe signal that ended the run, such as 'SIGINT'. Null otherwise.
errorDebugEventErroryesThe failure: name, message, code, and stack. Branch on code, never on message. Null when nothing failed.
outputDebugEventOutputyesWhat the run answered with: JSON mode, the envelope types, the result count and kind, and the stdout and stderr it printed, scrubbed and cut at the capture limit.
envDebugEventEnvyesRuntime facts: the CLI and Node versions, platform, CI, package manager, and whether a person, an AI agent, or automation ran the command. It never holds a hostname, a user name, or a raw session ID.
projectDebugEventProjectyesThe project the command ran in, as flags and counts. It never holds package names.
redactedbooleanyesWhether the values in the record were scrubbed. It is true on every event a handler receives. When it is false, treat the log as sensitive.
Keep a local log of the runs that failed
js
import {appendFileSync} from 'node:fs';
​
export default {
debug: event => {
if (event.outcome !== 'ok') {
appendFileSync('failed-runs.ndjson', JSON.stringify(event) + '\n');
}
},
};

The shape only grows: fields are added, never removed or given a new meaning. When a meaning must change, schemaVersion gets a new value. In version 3, a null output.resultKind means the run never reached an answer, so do not count it together with a version 1 or 2 null.

The handler runs synchronously as the process exits, so a promise it returns is never awaited. It gets a copy of the event, and anything it throws is ignored.

Import DebugEventOutput, DebugEventEnv, DebugEventProject, and DebugEventError from @astryxdesign/cli/authoring for the fields of output, env, project, and error.

GapReportHandler

A handler for npx @astryxdesign/cli gap-report: a plain object with an audience and a handle function. Set it as gapReport in astryx.config, or export it as gapReport from an integration manifest. Handlers add up rather than replace each other: the project handler first, then each loaded integration's in load order (the integrations list, then autolinked ones), whichever package the report is about.

Applies to: gapReport in astryx.config.*, or the gapReport named export of astryx.integration.*

FieldTypeRequiredDescription
audience'internal' | 'public'yesWhether the destination is internal to your organization or public. A public handler runs only when the caller agrees; an internal handler always runs.
handle(report: GapReport, context: GapReportHandlerContext) => GapReportHandlerReceipt | Promise<GapReportHandlerReceipt>yesGets the report and returns a receipt. It can be async, and the CLI waits at most 30 seconds for it. A throw, a timeout, or an invalid receipt is a failed delivery, and the other handlers still run.
reportGapReportyesThe report. Each handler gets its own copy.
report.schemaVersion1yesVersion of the report shape.
report.componentstringyesThe component the gap is about.
report.categoryGapReportCategoryyesThe kind of gap: 'missing_component', 'missing_variant', 'layout_gap', 'styling_gap', 'a11y_gap', 'api_friction', 'docs_gap', or 'other'.
report.categoryLabelstringyesA readable name for the category.
report.intentionstringyesWhat the caller was trying to do.
report.detailstringnoMore detail, when the caller gave some.
report.sourceDebugInvocationSourceyesWho sent the report: 'human', 'ai', 'automation', or 'unknown'.
report.timestampstringyesWhen the report was sent, as ISO 8601.
report.targetGapReportTargetyesThe package the report is about.
report.target.packagestringyesThe package name.
report.target.versionstringyesThe integration's version from its package.json. Null for Core, and when the package.json has none.
report.target.issuesUrlstringyesWhere the package takes issues. Null when it does not say.
contextGapReportHandlerContextyesThe context of this call.
context.signalAbortSignalyesAborted when the handler runs past 30 seconds.
A project handler in astryx.config
js
export default {
gapReport: {
audience: 'internal',
async handle(report, {signal}) {
const url = await fileIssue(report, {signal});
return {status: 'filed', url};
},
},
};
Receipt fieldTypeRequiredDescription
status'filed' | 'routed_only' | 'skipped'yes'filed': the handler created or queued the report. 'routed_only': it points to a place to file the report, and must return url. 'skipped': it chose not to act, for example on a duplicate.
urlstringnoAn http or https URL. Required when status is routed_only.
messagestringnoA readable message of at most 2000 characters.

The receipt is strict: an unknown field, a URL that is not http or https, or a filed or routed_only receipt with neither url nor message is invalid.

DiscoverSource

A source for npx @astryxdesign/cli discover: an async function that returns a catalog of packages a project could add, their versions, and what each version adds. Set it as discover in astryx.config, or export it as discover from an integration manifest. Discover calls every source, the project one first, and then reads the official list the CLI carries; another source's entry for an official package is kept. A source that throws, runs past 30 seconds, or returns an invalid catalog never hides the others; discover then uses the last good answer that source gave for the same request, or reports the source as failed when it saved none. Discover only reads: it prints the command that adds a package and never runs it.

Applies to: discover in astryx.config.*, or the discover named export of astryx.integration.*

FieldTypeRequiredDescription
contextDiscoverSourceContextyesThe one argument the source is called with.
context.signalAbortSignalyesAborted when the source runs past 30 seconds.
context.packagestringnoSet when discover shows one package: return that package with every version.
context.versionstringnoWith package: return that version's contributions. Without it, the latest release's.
returnsPromise<DiscoverCatalog>yesThe catalog. Discover checks it, ignores fields and item kinds it does not know, and refuses any schemaVersion but 1.
schemaVersion1yesVersion of the catalog shape.
source{name: string, generatedAt: string, complete: boolean}yesWho answered, when the data was produced (ISO 8601), and false when the source knows its list is partial.
packagesDiscoverPackage[]yesOne entry per npm package. When two sources list the same package, the earlier source wins.
packages[].packagestringyesThe npm name.
packages[].integrationstringyesShared by every npm name that publishes the same integration. Discover lists an integration once.
packages[].aliasesstring[]yesThe integration's other npm names. Discover never offers a package the project has under another name.
packages[].descriptionstringnoOne line, for the list and search.
packages[].lateststring | nullyesThe latest release. Null when there are only prereleases.
packages[].versionsDiscoverVersion[]yesEvery version, newest first: {version, publishedAt, prerelease, status}, where status is ok or why the version could not be read.
packages[].contributionsDiscoverContribution[]yesWhat the requested (else latest) version adds: {kind, name, title?, summary?, keywords?}, where kind is component, template, doc, theme, codemod, or agent-doc (a DiscoverKind) and name is the name the CLI uses for it.
A project source in astryx.config
js
export default {
async discover({signal, package: name, version}) {
const res = await fetch(catalogUrl(name, version), {signal});
return res.json();
},
};

In global mode: the discover source of an integration installed for the computer is called in folders outside any project; inside a project, only the sources of integrations the project installs are. See npx @astryxdesign/cli docs global and npx @astryxdesign/cli docs authoring/global-integrations.

Astryx Codemod

A codemod module the CLI runs during npx @astryxdesign/cli upgrade. Default-export a plain object with a type discriminant: 'code' rewrites source files, 'config' rewrites the astryx.config.* file. There is no factory to call.

Applies to: <codemods>/<version>/<id>.{ts,mjs,js} in an integration package: <codemods> is the codemods root of astryx.integration.*, and <version> is the exact @astryxdesign/core version (such as 0.7.0) whose upgrade runs the codemod. A file directly in the root never runs.

FieldTypeRequiredDescription
titlestringyesShort, human-readable title shown in upgrade output. Example: 'Rename Button prop kind to variant'.
descriptionstringnoOptional longer description.
isOptionalbooleannoWhen true, the codemod runs only when explicitly requested. Default: false.
fileExtensionsstring[]noFile extensions this codemod applies to. Code codemods only; a config codemod always targets astryx.config.*. Default: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs']. Example: ['.tsx', '.ts'].
transform(file: AstryxCodemodFile, api: AstryxCodemodApi) => string | null | undefinedyesThe transform. Return the new source to rewrite the file, or null/undefined to leave it unchanged.
fileAstryxCodemodFileyesThe source file presented to the transform.
file.pathstringyesAbsolute path to the file being transformed.
file.sourcestringyesThe current source contents of the file.
apiAstryxCodemodApiyesHelpers and context passed as the second argument.
api.jscodeshiftunknownyesA jscodeshift instance configured with a parser for the file.
api.stats(...args: unknown[]) => voidyesAccepted for jscodeshift parity. A call does nothing, and upgrade shows nothing for it.
api.report(...args: unknown[]) => voidyesAccepted for jscodeshift parity. A call does nothing, and upgrade shows nothing for it.
type'code' | 'config'yesDiscriminant for the file-transforming variant. Use 'config' for a codemod that rewrites astryx.config.* instead (see notes). Example: 'code'.
Code codemod (type: code)
js
export default {
type: 'code',
title: 'Rename Button prop kind to variant',
fileExtensions: ['.tsx', '.ts'],
transform(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
// ...rewrite the AST...
return root.toSource();
},
};
Config codemod (type: config)
js
export default {
type: 'config',
title: 'Move issuesUrl into astryx.config',
transform(file, api) {
// Rewrite the astryx.config.* source; return null to skip.
return null;
},
};

The config-codemod variant (type: 'config') carries the same fields as a code codemod except fileExtensions: it always targets the astryx.config.* file rather than a set of source files.

Authors write a plain object and default-export it with the type discriminant; there is no factory. The CLI validates it at the load boundary via parseCodemod with a strict schema, and isOptional defaults to false.

Provider and artifact identity

Separates stable provider/artifact identity from package instances and runtime lifecycle state. Provider identity names integration manifests and settles provider conflicts. Docs-tree nodes are identified by doc identity; the tree covers the CLI's docs and each integration's. Artifact IDs for kinds outside the docs tree, provider instances, and compiler-input entries are defined for every provider-owned artifact; no command builds them.

Applies to: Integration manifests, provider conflicts, and docs-tree node ids.

FieldTypeRequiredDescription
ProviderIdstringyesCanonical logical provider ID. It defaults to the lowercase npm package name; Core uses the same model without publishing an integration manifest.
ArtifactIdstringyesVersioned serialization of provider ID, contribution kind, and stable artifact name. Every segment uses RFC 3986 escaping.
DocIdArtifactIdyesArtifact ID restricted to one authored doc kind. Navigation changes do not change it.
ProviderInstance{ id; providerId; packageName; packageVersion; sourceDigest }yesOne immutable package version and source/content digest. It carries no installed, configured, loaded, selected, or healthy state.
AuthoredDocEntry{ id; provider; kind; stableName; source; authored }yesNormalized compiler input. stableName, the key in its doc ID, is given separately from the authored display name; source paths remain package-relative, and provider provenance plus the authored snapshot are immutable.

Provider package renames require an explicit mapping: a ProviderInstance may retain its stable providerId while packageName changes. Silent identity changes are not inferred.

Authored doc graph fields

Fields every authored doc kind can declare for the docs tree: placement, plus two reserved fields, aliases and audience. The docs tree reads placement for every guide and namespace doc, the CLI's and each integration's. Nothing reads aliases or audience today: a reference topic that sets one fails to load, placed in the docs tree or not, and other doc kinds accept them and ignore them.

Applies to: Every authored doc kind's .doc.mjs object. Theme descriptors take none of these fields.

FieldTypeRequiredDescription
placementDocPlacementnoNames the doc's one parent in the docs tree: a namespace of the same package, one of its slots, and an order. Read for every guide, the CLI's and each integration's, and for every namespace doc: a guide with placement gets a route in the tree instead of a flat topic name, and cannot also set replaces or extends; a namespace doc with placement nests under that parent. In the CLI's own topic directory a topic that sets it fails to load: the CLI keeps its guides in its docs tree directory. Commands, API functions, schemas, and enums do not set it: the tree adopts each by its namespace.
placement.parentstringyesThe parent namespace: namespace:<name> in the same package.
placement.slotstringnoA slot the parent namespace declares; it must accept this doc's kind. Optional when the parent has one slot.
placement.ordernumbernoInteger sibling order within the slot. Docs without an order come after the ordered ones, sorted by title. The same order sets each doc's Previous and Next links.
aliasesstring[]noReserved: prior names or routes the docs tree will keep resolving to this doc, without creating another identity. Nothing reads it today, and a topic that sets it fails to load.
audience'public' | 'internal'noReserved: which docs bundle includes this doc ('public' when omitted). Nothing reads it today, and a topic that sets it fails to load. Default: 'public'.

Unknown fields: component, function, generic, schema, command and enum docs accept a field they do not know, and nothing reads it, so a doc written for a newer CLI still loads here. page, block and namespace docs refuse one, as do theme descriptors. Sections and content blocks refuse one in every doc.

ComponentDoc

The doc-type for a component directory's {Name}.doc.mjs. A discriminated union of SingleComponentDoc (props on the doc), MultiComponentDoc (a components array), and SubComponentDoc (a subComponentOf pointer). All three share the ComponentBaseDoc fields below; the variant is chosen by which of props / components / subComponentOf you set.

Applies to: {Name}.doc.mjs

FieldTypeRequiredDescription
type'component'noDoc-kind discriminant for the stamped default-export format. Optional: legacy export const docs = {...} docs omit it and the parser falls back to shape-sniffing.
namestringyesStable machine identity without the Astryx prefix, PascalCase. e.g. 'Button', 'TextInput', 'AppShell'. For an integration component it is the export apps import: the doc sits beside the file that exports it, and when that file exports nothing under its own name, name may name the export it does have. Change displayName, not name, to edit the visible label; registry URLs derive from this identity by default.
displayNamestringyesHuman-readable display name with spaces between words ('AppShell' → 'App Shell'). Drives the docsite gallery and sidebar label.
registryRegistryDocIdentitynoOptional public registry identity. The converter derives a stable kebab-case slug from name; set slug only to override it, and keep prior relative paths in aliases after a published rename.
importstringnoExact public package specifier consumers use to import an integration-owned component. The packed-package gate resolves this specifier and verifies it exports the component name.
replacesstringnoIntegration components only: the exact name of the Core ComponentDoc this component takes over for unqualified lookup, so every app that loads the integration gets it from component detail, component lists, search, swizzle <Name>, and issue routing; swizzle --list keeps listing Core names. The Core original stays reachable with --package @astryxdesign/core. It takes effect only when the package's peer range starts at the release that applies it, "@astryxdesign/cli": ">=0.6.7" or later; with a lower range nothing changes and doctor integration components warns (npx @astryxdesign/cli docs authoring/building-blocks/components/replace-a-core-component). Set it only to intentionally own a Core identity; give an alternative or variant its own name instead.
keywordsstring[]noSearch keywords for CLI discovery: synonyms and related UI concepts from other design systems (MUI, Chakra, Radix, and others). Lowercase. npx @astryxdesign/cli search matches them; npx @astryxdesign/cli component <term> also suggests Core components by keyword.
hiddenComponentsstring[]noSub-component names to hide from human-facing UI (CLI listings, docs catalogs). They stay public and importable; agents and tooling can still discover them via source. In an integration it hides only full entries in this doc's components, which the CLI then neither lists nor opens; hide a sub-component that has its own doc with hidden in that doc.
hiddenbooleannoHide this entire component from human-facing UI. It stays public and importable. Use for a shared primitive that only makes sense inside its parent.
groupstringnoOptional sidebar/docs group. Clusters related components; ungrouped components appear flat in alphabetical order.
category'Action' | 'Chat' | 'Container' | 'Content' | 'Form Controls' | 'Data Input' | 'Data Visualization' | 'Feedback & Status' | 'Layout' | 'Navigation' | 'Overlay' | 'Table & List' | 'Utility'noOverview-gallery category representing the component's functional role. Independent of group (which is for the sidebar). Data Input is a deprecated compatibility alias for Form Controls.
isHiddenFromOverviewbooleannoExclude from the categorized overview page while keeping the component in the sidebar and CLI. Use for sub-components or internal primitives.
theming{ container?: boolean; targets: ComponentThemingTarget[]; vars?: ComponentThemingVar[]; derived?: ComponentThemingDerivedVar[] }noTheming configuration: the stable selector surface (astryx-* classes + data-attribute reflections) that themes target via @scope selectors in defineTheme.
theming.containerbooleannoMarks a container component. Nothing reads it today: the theme pipeline maps padding to container tokens from a theming.derived entry {property: 'padding', expand: 'container'}.
theming.targetsComponentThemingTarget[]yesSelector targets rendered by this component. Each entry corresponds to a themeProps() call in the source.
theming.varsComponentThemingVar[]noCSS custom properties exposed for theming.
theming.derivedComponentThemingDerivedVar[]noMaps standard CSS properties to internal vars for theme-pipeline expansion. Ordered by priority: earlier entries emit first. The pipeline reads only Core components' entries, so an integration's have no effect.
usageUsageDocnoComponent usage documentation: concise summary, best practices, component-specific accessibility requirements, and optional visual anatomy. Required on a component doc; optional on a sub-component doc (subComponentOf), which uses its description instead.
usage.descriptionstringyesWhat the component is and when to use it, in 2-3 short sentences.
usage.bestPracticesComponentBestPractice[]no3-4 do/don't design-guidance items ({guidance: boolean, description: string}). Never start the description with 'Do' or 'Don't'.
usage.accessibilityComponentAccessibilityRequirement[]noComponent-specific requirements rendered in the shared Accessibility tab. Write at about a grade-7 reading level with short sentences, common words, and active voice. For color contrast, put the ratio in requirement; name the exact foreground, background, state, and any overlay in description; explain exceptions in plain language; and give a human or agent enough detail to reproduce the check. Keep repository audit procedures in the wiki rubric.
usage.accessibilityThemeCoverageComponentAccessibilityThemeCoverage[]noVerified per-theme accessibility measurements rendered in the shared Accessibility tab. Record light and dark mode separately, include rendered color pairs, and mark failed measurements. Put visuals excluded from the audit in notMeasured with a short reason; do not add them as table measurements. Each theme declares applicability for measured values, informed by the component contract and never inferred from the ratio: Conditional is required only in some contexts, Supplemental adds another meaningful cue, and Decorative has no required meaning. These values do not change row status. Provide a complete breakdown when one cell summarizes multiple combinations, and protect derived values with an automated audit.
usage.anatomyComponentAnatomyElement[]noStructural/visual parts in reading order ({name, required, description}).
examplesComponentExampleDoc[]noShort code examples ({label?, code}) rendered by the CLI after the props table.
playgroundComponentPlaygroundConfignoInteractive-preview config: initial prop defaults, overlay for modal-only components, appShellMobile for components gated on AppShell mobile context, and a wrapper for context-dependent sub-components.
propsComponentPropDoc[]noSingleComponentDoc variant (required there): all public props for the one primary component. Each prop is {name, type, description, default?, required?, slotElements?, stability?}. Skip styling props like xstyle/className/style. Also present on SubComponentDoc.
components(ComponentEntry | ComponentRef)[]noMultiComponentDoc variant (required there): one entry per public component/hook exported from the directory. Each entry is a full ComponentEntry (inline: name, displayName, description, props | params+returns) or a name-only ComponentRef pointing at a sibling {Name}.doc.mjs. In an integration, each full entry the module named by import exports is a component under its own name.
subComponentOfstringnoSubComponentDoc variant (required there): the parent component's name (e.g. 'Chat'). Marks this file as a sub-component doc. The CLI does not copy family fields (group, category, keywords, theming, playground) from the parent to an integration's sub-component: its detail, list group and search use only what its own doc sets. Set any the child needs in its own doc.
descriptionstringnoSubComponentDoc variant (required there): one-sentence description of the sub-component's role within the parent composition. Single/Multi docs have no top-level description; they derive their summary from usage.
SingleComponentDoc (props on the doc)
js
/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
export default {
type: 'component',
name: 'AcmeSwitch',
displayName: 'Acme Switch',
category: 'Form Controls',
keywords: ['toggle', 'switch', 'on off'],
usage: {
description:
'A Switch toggles a single setting on or off. Use it for instant, binary preferences that apply immediately without a submit step.',
bestPractices: [
{guidance: true, description: 'Apply the change immediately when toggled.'},
{guidance: false, description: 'Use a Switch for actions that need confirmation; prefer a Checkbox in a form.'},
],
},
props: [
{name: 'isSelected', type: 'boolean', description: 'Whether the switch is on.', required: true},
{name: 'onChange', type: '(isSelected: boolean) => void', description: 'Called when the user toggles the switch.'},
{name: 'isDisabled', type: 'boolean', description: 'Prevents interaction and dims the control.', default: 'false'},
],
};
MultiComponentDoc (a components array)
js
/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
export default {
type: 'component',
name: 'AcmeTable',
displayName: 'Acme Table',
category: 'Table & List',
usage: {description: 'Displays rows and columns of data. Compose the sub-components to build headers, rows, and cells.'},
components: [
{name: 'AcmeTable', displayName: 'Acme Table', description: 'The table container.', props: []},
{name: 'AcmeTableRow', displayName: 'Acme Table Row', description: 'A row within the table body.', props: [
{name: 'isSelected', type: 'boolean', description: 'Highlights the row as selected.'},
]},
{name: 'useAcmeTableSelection', displayName: 'useAcmeTableSelection', description: 'Manages row selection state.',
params: [{name: 'rows', type: 'T[]', description: 'The rows to track.', required: true}],
returns: [{name: 'selectedIds', type: 'Set<string>', description: 'Currently selected row ids.'}]},
],
};

When it loads, a stamped component doc is checked about as loosely as an unstamped one, so adding type: 'component' to a doc that follows the type never breaks it: displayName may be missing, category may be any string, usage, theming and playground are not checked, and examples need only be an array. Unlike an unstamped doc, each entry in a group doc's components must have a name, and import and replaces, when set, must be non-empty strings. Write to the type anyway; it is the contract.

ComponentDoc is a discriminated union of three shapes that all extend ComponentBaseDoc. Pick the variant by which key you set: props (single), components (multi), or subComponentOf (sub).

  • SingleComponentDoc: one primary component; put props directly on the doc via props. Use for Switch, Badge, Spinner, TextInput.
  • MultiComponentDoc: a directory exporting several components/hooks; list them in components (inline ComponentEntry or name-only ComponentRef). Use for Table, Dialog, TabList.
  • SubComponentDoc: a single sub-component in its own {Name}.doc.mjs inside the parent directory; set subComponentOf to the parent name. It does not take the parent's family fields, and it may omit usage.
SubComponentDoc
js
/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
export default {
type: 'component',
name: 'AcmeChatComposer',
displayName: 'Acme Chat Composer',
subComponentOf: 'AcmeChat',
description: 'The message input row within an Acme Chat, with an editor and send affordance.',
props: [
{name: 'onSend', type: '(text: string) => void', description: 'Called when the user submits a message.', required: true},
],
};

The stamped format is export default { type: 'component', ... }, which npx @astryxdesign/cli integration add component writes; legacy docs use export const docs = {...} and omit type (the parser shape-sniffs). A hook that is part of a component API is listed in a MultiComponentDoc components array: inline as a ComponentEntry (with params/returns), or by name with its own SubComponentDoc file, as Core's useTableSelection is.

HookDoc

The doc-type for a standalone React hook (e.g. useMediaQuery, useFocusTrap, useOverflow) that gets its own .doc.mjs. Hooks that are part of a component API (e.g. useImperativeDialog) belong in that component's MultiComponentDoc components array instead. HookDoc is the hook-flavored view of the shared type: 'function' kind. npx @astryxdesign/cli hook reads Core's hook docs only; an integration lists its hooks in a component doc's components array.

Applies to: {useX}.doc.mjs

FieldTypeRequiredDescription
type'function'noDoc-kind discriminant (shared with FunctionDoc). Legacy export const docs = {...} docs omit it.
namestringyesStable hook name exactly as exported, e.g. 'useMediaQuery', 'useFocusTrap'. Change displayName, not name, to edit the visible label; registry URLs derive from this identity by default.
displayNamestringyesHuman-readable display name. Hooks read better as the raw identifier ('useMediaQuery') than spaced, so keep the identifier verbatim.
registryRegistryDocIdentitynoOptional public registry identity. The converter derives a stable kebab-case slug from name; set slug only to override it, and keep prior relative paths in aliases after a published rename.
groupstringnoOptional group for sidebar/docs organization; same as ComponentDoc.group.
keywordsstring[]noSearch keywords for CLI discovery.
paramsHookParamDoc[]yesHook parameters or options-object fields.
params[].namestringyesParameter or option field name.
params[].typestringyesTypeScript type signature as a string.
params[].descriptionstringyesWhat this parameter does. 1-2 sentences.
params[].defaultstringnoDefault value as a string, if optional with a default.
params[].requiredbooleannoTrue if required. Omit if optional.
returnsHookReturnDoc[]yesReturn value documentation. For object returns, list each field; for primitive returns, use a single entry.
returns[].namestringyesField name on the returned object, or 'value' for primitive returns.
returns[].typestringyesTypeScript type.
returns[].descriptionstringyesWhat this return value provides.
usageUsageDocyesUsage documentation: description and best practices.
usage.descriptionstringyesWhat the hook is and when to use it, in 2-3 short sentences.
usage.bestPracticesComponentBestPractice[]no3-4 do/don't design-guidance items ({guidance: boolean, description: string}).
usage.accessibilityComponentAccessibilityRequirement[]noAccessibility requirements specific to using the hook ({name, description}).
usage.anatomyComponentAnatomyElement[]noStructural/visual anatomy elements (rarely used for hooks).
relatedComponentsstring[]noComponent names this hook is commonly used with. Enables cross-referencing (e.g. npx @astryxdesign/cli hook useToast links back to Toast).
relatedHooksstring[]noOther hook names this hook is commonly used with.
importPathstringnoImport path, e.g. '@astryxdesign/core/hooks' or '@astryxdesign/core/Toast'.
categorystringnoCategory for grouping in listings.
A standalone hook doc
js
/** @type {import('@astryxdesign/cli/authoring').HookDoc} */
export const docs = {
type: 'function',
name: 'useMediaQuery',
displayName: 'useMediaQuery',
importPath: '@astryxdesign/core/hooks',
params: [
{name: 'query', type: 'string', description: 'A CSS media query, e.g. "(min-width: 768px)".', required: true},
],
returns: [
{name: 'matches', type: 'boolean', description: 'Whether the query currently matches.'},
],
usage: {
description:
'Subscribes to a CSS media query and re-renders when it changes. Use for responsive behavior that CSS alone cannot express, such as swapping components by breakpoint.',
},
relatedHooks: ['useImageMode'],
};

parseDoc accepts a stamped hook doc without displayName and does not check its usage, and npx @astryxdesign/cli hook prints a hook doc without checking it at all. Write to the type anyway; it is the contract.

A hook's discriminant is type: 'function': HookDoc and FunctionDoc share the generalized function kind. HookDoc is the hook-flavored view: named returns fields and a required usage block.

A hook that is part of a component API is listed in that component's MultiComponentDoc components array: inline with params/returns, or by name with its own SubComponentDoc file, as Core's useTableSelection is.

FunctionDoc

The generalized type: 'function' doc-type covering both React hooks and CLI/API functions. A hook (HookDoc) is the hook-flavored view of this same kind; FunctionDoc adds the fields an API function needs (a {type, data} return envelope, thrown error codes, the wrapping CLI command). The CLI binding itself lives in a separate CommandDoc.

Applies to: api/<dir>/<name>.doc.mjs

FieldTypeRequiredDescription
type'function'noDoc-kind discriminant (shared with hooks).
namestringyesExport name, e.g. 'search' | 'useMediaQuery'.
displayNamestringyesHuman-readable display name, e.g. 'search()'.
kind'hook' | 'api'noWhich flavor. Nothing reads it today: no reader sections docs by it or infers it from importPath. The CLI's API function docs set 'api'.
summarystringnoOne-line summary.
descriptionstringnoLonger description.
namespacestringnoOptional in the type, but every doc the CLI ships declares it. The group that reads this doc. The CLI's API functions use 'cli/api', which the docs tree adopts by kind: each is the leaf cli/api/functions/<name>, the name in kebab case (themeUse is theme-use). Every function doc the CLI ships declares one, and npx @astryxdesign/cli doctor fails on one that is missing or that nothing reads.
aliasesstring[]noReserved: other names for this doc. Nothing reads it today, so they do not resolve to it.
keywordsstring[]noSearch keywords for discovery.
importPathstringnoImport path, e.g. '@astryxdesign/cli/api' | '@astryxdesign/core/hooks'.
signaturestringnoFull signature as a string, e.g. 'search(query, options?): Promise<SearchResponse>'.
paramsHookParamDoc[]yesParameters / options-object fields.
params[].namestringyesParameter or option field name.
params[].typestringyesTypeScript type signature as a string.
params[].descriptionstringyesWhat this parameter does. 1-2 sentences.
params[].defaultstringnoDefault value as a string, if optional with a default.
params[].requiredbooleannoTrue if required. Omit if optional.
returnsFunctionReturnDoc[]yesReturn documentation. Hooks list named return fields (name set); API functions list {type, data} envelope entries where type is the response discriminant and name is omitted.
returns[].namestringnoField name (hooks); omit for API envelope entries.
returns[].typestringyesTS type (hooks) or response-type discriminant (API), as a string.
returns[].descriptionstringyesWhat this return value / envelope entry provides.
throwsFunctionThrowsDoc[]noErrors the function throws (API functions), keyed to ERROR_CODES.
throws[].codestringyesThe ERROR_CODES member thrown.
throws[].whenstringyesThe condition under which it is thrown.
examplesFunctionExampleDoc[]noUsage examples.
examples[].labelstringnoOptional heading shown above the snippet.
examples[].codestringyesLanguage-level usage, e.g. "await search('button')".
examples[].resultstringnoOptional sample result.
usageUsageDocnoUsage documentation (hooks): description, best practices, accessibility requirements, and anatomy. Same shape as HookDoc.usage.
commandstringnoThe CLI command that wraps this function, e.g. 'search'.
relatedstring[]noRelated function names; the CLI links each to its doc. Name the CLI command with command, not here.
relatedComponentsstring[]noComponent names this is commonly used with (hooks).
relatedHooksstring[]noOther hook names this is commonly used with (hooks).
categorystringnoCategory for grouping in listings.
An API function doc (envelope return, no field name)
js
/** @type {import('@astryxdesign/cli/authoring').FunctionDoc} */
export const doc = {
type: 'function',
kind: 'api',
name: 'search',
displayName: 'search()',
namespace: 'cli/api',
importPath: '@astryxdesign/cli/api',
summary: 'Find components, hooks, docs, templates, and themes by term.',
signature: 'search(query, options?): Promise<SearchResponse>',
params: [
{name: 'query', type: 'string', description: 'The search term.', required: true},
{name: 'options.type', type: "'component' | 'hook' | 'doc' | 'template' | 'theme'", description: 'Restrict results to one domain.'},
],
returns: [
{type: 'search', description: 'Envelope with the query and ranked results[].'},
],
throws: [
{code: 'ERR_INVALID_ARGUMENT', when: 'query is empty.'},
],
examples: [
{label: 'Basic', code: "await search('button')", result: "{ type: 'search', data: { results: [...] } }"},
],
command: 'search',
};

parseDoc accepts a stamped function doc without displayName and does not check its usage, and the CLI reads its own function docs without checking them. Write to the type anyway; it is the contract.

The type discriminant is 'function' for both flavors. kind: 'hook' or kind: 'api' names the flavor; nothing reads it today.

The two flavors differ in returns: a hook lists named fields (name set), while an API function lists {type, data} envelope entries whose type is the response discriminant and whose name is omitted. throws (keyed to ERROR_CODES) applies to API functions.

The function does not know it has a CLI. The terminal binding (args, flags, exit codes) lives in a separate CommandDoc that references this function via fn; here you only note the wrapping command name via command.

CommandDoc

The doc-type for a CLI command: the terminal binding of an operation. A command is not its own behavior; it is a FunctionDoc exposed on the CLI, referenced via fn, carrying only CLI-surface facts (args, flags, subcommands, examples, exit codes). A defineCommand converter turns it into Commander config + --help.

Applies to: clients/cli/commands/<name>.doc.mjs

FieldTypeRequiredDescription
type'command'noDoc-kind discriminant. Marks the file as a command doc.
namestringyesCommand path, e.g. 'search' | 'theme build'.
displayNamestringyesHuman-readable display name, e.g. 'astryx search'.
summarystringyesOne-line summary → Commander .description() + the docs listing.
descriptionstringnoLonger body / when-to-use. npx @astryxdesign/cli docs prints it after the summary; --help shows only the summary.
namespacestringnoOptional in the type, but every doc the CLI ships declares it. The group that reads this doc. The CLI's commands use 'cli/commands', which the docs tree adopts: each is the leaf cli/commands/<name>, spaces as hyphens (theme build is cli/commands/theme-build). Every command doc the CLI ships declares one, and npx @astryxdesign/cli doctor fails on one that is missing or that nothing reads.
aliasesstring[]noReserved: other names for this doc. Nothing reads it today, so they do not resolve to it.
fnstringnoName of the FunctionDoc (and @astryxdesign/cli/api export) this command wraps. Example: 'search'.
argsCommandArgDoc[]noPositional arguments.
args[].namestringyesArgument name.
args[].paramstringnoFunctionDoc param this arg binds to; npx @astryxdesign/cli docs shows that param's description when the arg has none.
args[].descriptionstringnoOverride description. --help shows an argument's description only when it sets this.
args[].requiredbooleannoWhether the positional argument must be supplied.
args[].variadicbooleannoWhether the argument collects a variable number of values.
optionsCommandOptionDoc[]noFlags/options.
options[].flagstringyesCommander flag spec, e.g. '-l, --limit <n>' | '--json'.
options[].paramstringnoFunctionDoc param this flag maps to (inherits its description).
options[].descriptionstringnoOverride/explicit description. Nothing requires it: a flag with neither this nor param shows no description.
options[].choicesstring[]noAllowed values for the flag.
options[].defaultstring | boolean | string[]noDefault value: a string, a boolean, or a list of strings.
options[].cliOnlybooleannoTrue for CLI-only flags with no function param (e.g. --verbose). Nothing reads it.
subcommandsstring[]noSubcommand names (for command groups like theme / layout).
examplesCommandExampleDoc[]noTerminal examples.
examples[].labelstringnoOptional heading shown above the invocation.
examples[].clistringyesA full terminal invocation, e.g. 'astryx search button --json'.
examples[].outputstringnoOptional sample output.
exitCodes{ code: number; when: string }[]noDocumented exit codes.
exitCodes[].codenumberyesThe process exit code.
exitCodes[].whenstringyesThe condition that produces this exit code.
outsideProject'same' | 'refuse' | 'own'noWhat the command does outside a project. same: the answer it gives in a new project. refuse: it writes nothing and fails, naming the app project or integration package it needs. own: something else. Every refuse and own command also carries an In global mode: note in its notes that tells readers exactly what it does. Every command and subcommand declares one, and npx @astryxdesign/cli manifest publishes it; a command that only groups subcommands declares none. The CLI tests run every command in an empty folder and check what it does against this value; npx @astryxdesign/cli doctor does not check it.
deprecatedstringnoDeprecation notice — a DEP-* id and the replacement.
relatedstring[]noRelated command names.
notesReferenceContentBlock[]noFreeform prose/notes.
A command that wraps the search() function
js
/** @type {import('@astryxdesign/cli/authoring').CommandDoc} */
export const doc = {
type: 'command',
name: 'search',
displayName: 'astryx search',
summary: 'Find components, hooks, docs, templates, and themes.',
namespace: 'cli/commands',
fn: 'search',
args: [{name: 'query', param: 'query', required: true}],
options: [
{flag: '--type <domain>', param: 'options.type', choices: ['component', 'hook', 'doc', 'template', 'theme']},
{flag: '--verbose', cliOnly: true, description: "Also print each result's score and match reason."},
],
examples: [{label: 'Terminal', cli: 'astryx search button --json'}],
exitCodes: [{code: 1, when: 'The --type value is not a known domain.'}],
related: ['discover'],
};

A command carries only CLI-surface facts. Behavior, parameters, returns, and thrown errors live in the FunctionDoc it points at via fn; the function does not know it has a CLI.

Bind an arg/option to a function param via param so it inherits that param's description: an option's in --help and npx @astryxdesign/cli docs, an arg's in npx @astryxdesign/cli docs only. Mark a flag with no function param (e.g. --verbose) cliOnly: true and give it its own description; nothing checks either. --json is a global flag, so a command doc does not declare it.

EnumDoc

The doc-type for a closed vocabulary: a fixed set of literal values such as error codes or response-type discriminants. Colocated as a .doc.mjs next to the source of truth it documents. Only the CLI reads enum docs, from its own source: one in an integration's docs directory fails to load, and so do that package's other docs.

Applies to: <enum>.doc.mjs

FieldTypeRequiredDescription
type'enum'noDoc-kind discriminant. Optional in the type, but the CLI reads a doc as an enum doc only when it is set; without it the doc drops out of the docs tree.
namestringyesURL-safe identifier, used as the docs slug within its namespace. Example: 'error-codes'.
displayNamestringyesHuman-readable title. Example: 'Error Codes'.
descriptionstringyesOne-line summary shown in listings.
namespacestringnoOptional in the type, but every doc the CLI ships declares it. The group that reads this doc. The CLI's enums use 'cli/api', which the docs tree adopts by kind: each is the leaf cli/api/enums/<name>. Every enum doc the CLI ships declares one, and npx @astryxdesign/cli doctor fails on one that is missing or that nothing reads.
aliasesstring[]noReserved: alternate slugs for this doc. Nothing reads it today, so npx @astryxdesign/cli docs <alias> does not open it.
membersEnumMemberDoc[]yesThe enumerated members: one entry per literal value.
members[].valuestringyesThe literal value, e.g. 'ERR_UNKNOWN_TOPIC' | 'component.list'.
members[].descriptionstringyesWhat the value means / when it occurs.
members[].deprecatedstringnoDeprecation reason, if deprecated.
Error-codes enum doc
js
/** @type {import('@astryxdesign/cli/authoring').EnumDoc} */
export const doc = {
type: 'enum',
name: 'error-codes',
displayName: 'Error Codes',
namespace: 'cli/api',
description: 'Stable error codes thrown by the CLI/API and surfaced in the JSON envelope.',
members: [
{value: 'ERR_UNKNOWN_TOPIC', description: 'The requested docs topic does not exist.'},
{value: 'ERR_INVALID_ARGUMENT', description: 'A required argument was missing or malformed.'},
{value: 'ERR_LEGACY', description: 'Old alias.', deprecated: 'Use ERR_INVALID_ARGUMENT.'},
],
};

An enum doc is the human-readable mirror of a closed vocabulary defined elsewhere in source (e.g. an ERROR_CODES map or a response-type union). Keep the members in sync with that source of truth.

Mark a value with deprecated (a migration hint) rather than deleting it, so old codes stay documented while readers are pointed at the replacement.

NamespaceDoc

Declares one level of the docs tree: named slots, and adoption rules for already-discovered docs. It never scans folders, lists its children, or copies child documents: a child names its parent with placement, or a namespace adopts a discovery group. npx @astryxdesign/cli docs <route> lists each slot's children one level down. The CLI keeps its namespace docs in its docs tree directory; an integration ships its own in its docs directory, and they appear in npx @astryxdesign/cli docs beside the CLI's.

Applies to: <namespace>.doc.mjs

FieldTypeRequiredDescription
type'namespace'yesDoc-kind discriminant.
namestringyesStable provider-local identity. Moving the namespace does not change this value.
titlestringyesHuman-readable page title.
summarystringyesOne-line summary used in listings and search results.
placementDocPlacementnoOptional canonical parent: {parent, slot?, order?}. parent names a namespace of the same package as namespace:<name>. An invalid placement withdraws the namespace with a diagnostic; it never falls back.
aliasesstring[]noPrior names or routes the docs tree will keep resolving to this doc. Not read yet.
audience'public' | 'internal'noReserved: bundle audience. Nothing reads it yet: a namespace that sets 'internal' still shows in npx @astryxdesign/cli docs. Default: 'public'.
keywordsstring[]noSearch terms not already present in the title or summary.
slotsRecord<string, NamespaceSlot>yesNamed placement and collection targets. Each slot declares a title and the doc kinds it accepts. accepts.providers is not read yet: only docs of the namespace's own package can be placed in a slot.
adoptsNamespaceAdoptionRule[]noProvider-local rules that adopt otherwise-unplaced docs from a logical discovery group. They never scan a folder.
blocks(ReferenceContentBlock | GraphContentBlock)[]noOrdered layout content for the namespace page. Graph-only workflow, collection, and reference blocks are available here without widening the stable ReferenceContentBlock union used by existing topic renderers. npx @astryxdesign/cli docs <route> prints the prose, heading, code, table, and list blocks above the slots, and --json returns every block with its links resolved. A collection block is not rendered yet, and npx @astryxdesign/cli doctor warns on one.
A CLI namespace with one adopted source group
js
/** @type {import('@astryxdesign/cli/authoring').NamespaceDoc} */
export const docs = {
type: 'namespace',
name: 'cli',
title: 'Astryx CLI',
summary: 'Commands, APIs, and integration authoring.',
slots: {
guides: {title: 'Guides', accepts: {kinds: ['namespace', 'generic']}},
reference: {
title: 'Reference',
accepts: {kinds: ['namespace', 'command']},
},
},
adopts: [{
source: {group: 'cli/commands', kinds: ['command']},
into: 'reference',
}],
};

The docs tree reads namespace docs from the CLI and from every configured integration. An integration's namespace doc without a placement is a top-level level of the tree, and its guides name it with placement; a doc can be placed only in a namespace of its own package. When two packages claim one route, the CLI's own docs win, then integrations in configured order, and npx @astryxdesign/cli doctor names the loser.

Child docs request one canonical home with placement. A collection block refers to those docs by stable reference and never creates a second identity or parent. It is not rendered yet: npx @astryxdesign/cli doctor warns on one.

GuidancePractices
Don't

Use a directory as implicit navigation.

Don't

Put JSX, HTML, ANSI, callbacks, or custom renderer code in a doc.

Don't

Use choice, callout, or checklist blocks before the full block-extension contract exists.

ReferenceDoc

The doc-type for a reference/topic doc: tokens, principles, theming, patterns, accessibility, migration guides. Unlike ComponentDoc it is not tied to a component: drop a .doc.mjs in the docs directory and it shows up in npx @astryxdesign/cli docs. Content is built from ordered sections of mixed content blocks.

Applies to: <topic>.doc.{ts,mjs,js} in the folder the integration manifest's docs names

FieldTypeRequiredDescription
type'generic'noDoc-kind discriminant. Stays 'generic' (the reference/topic discriminant). Legacy export const docs = {...} docs omit it.
namestringyesURL-safe identifier, used as the CLI topic name. e.g. 'tokens', 'principles'.
titlestringyesHuman-readable title. e.g. 'All Tokens'. (Reference docs use title, not displayName.)
descriptionstringyesOne-line summary shown in topic listings.
categorystringnoNavigation category: 'guide' or 'foundations'.
keywordsstring[]noWords a reader may search for that the title and sections do not use: a synonym, a task, or another library's name for the same thing. npx @astryxdesign/cli search matches each as a keyword of the whole topic, so an exact one ranks the topic like its own title does. Example: ['dark mode', 'color scheme'].
replacesstringnoName of an existing topic this doc takes the place of. Authored by an integration that serves its own guide instead of the built-in one: on a doc of the same name it swaps the content, and on a doc of another name it also leaves the old name as an alias so npx @astryxdesign/cli docs <old> still resolves. Exclusive with extends. Example: 'getting-started'.
extendsstringnoName of an existing topic this doc merges onto, section by section. A section replaces the base section with the same id, or else the one with exactly its title when either of the two has no id. A key derived from a title counts only against an id: a section without an id also matches a base section whose id is the key its title derives, and a section with an id also matches a base section without one whose title derives that id. Two different ids never match, two sections without an id whose titles differ only in case or punctuation stay separate, and a section that matches none is appended. The topic keeps the base's title and description. For correcting or adding to a topic rather than owning it. Exclusive with replaces. Example: 'theme'.
sectionsReferenceSection[]yesOrdered sections that make up the doc. Each becomes an h2 in full output and can be retrieved via npx @astryxdesign/cli docs <topic> <section>.
sections[].idstringnoStable section anchor. New docs should set this instead of relying on a mutable title. Published @astryxdesign/cli 0.6.3 and earlier reject the field and hide every doc topic the package ships, so npx @astryxdesign/cli integration verify fails an integration that sets one until it declares an @astryxdesign/cli peer of >=0.6.4.
sections[].titlestringyesSection title, e.g. "Spacing Tokens", "Light/Dark Mode".
sections[].categorystringnoNavigation category ('guide' | 'foundations'). Mirrors the parent doc's category so sections can be grouped independently.
sections[].content(ReferenceContentBlock | ReferenceDocBlock)[]yesOrdered content blocks: prose, heading, code, table, list, token-ref, and reference. A reference block ({type: 'reference', target, projection?, presentation?}) includes another doc from its canonical source instead of a copy: a schema, command, function, or enum doc as npx @astryxdesign/cli docs prints it, then the command that opens it. projection.fields keeps only those fields of a schema; presentation is full (the default), compact (no code blocks), or summary (only the doc's title, summary, and command). Any other doc shows as a summary. Graph-only workflow and collection blocks are available through GraphContentBlock on NamespaceDoc, without widening the stable ReferenceContentBlock union. Inside text, {@link <target>} links another doc by identity ([<provider>:]<kind>:<name>): the CLI prints the command that opens it, and npx @astryxdesign/cli doctor warns on one that names no doc.
sections[].previewTypeReferenceTokenPreviewTypenoPreview type for token tables in this section. The CLI rejects a value not in the list below and returns the value in npx @astryxdesign/cli docs --json, but draws no preview from it. Omit for non-token sections.
tokenCategorystringnoToken category for foundational docs that map to a token section (e.g. 'color'). npx @astryxdesign/cli docs --json returns it as authored; no tokens page links to the doc through it.
A reference doc with one section
js
/** @type {import('@astryxdesign/cli/authoring').ReferenceDoc} */
export default {
type: 'generic',
name: 'acme-spacing',
title: 'Acme Spacing',
description: 'Spacing tokens for gap, margin, and padding.',
category: 'foundations',
tokenCategory: 'spacing',
sections: [
{
title: 'Spacing Tokens',
content: [
{type: 'prose', text: 'Use spacing tokens instead of raw pixel values so layouts stay on the 4px grid.'},
{type: 'table', headers: ['Token', 'Value'], rows: [['--spacing-4', '16px']]},
],
previewType: 'spacing-bar',
},
],
};

A stamped generic doc without title, description or sections still loads, as older codemod output does; its title falls back to displayName or name. Without a description and sections it is not a usable topic, and npx @astryxdesign/cli doctor reports it.

Each sections[].content is an ordered array of ReferenceContentBlock, the stable discriminated union of prose, heading, code, table, list, and token-ref, plus the reference block (ReferenceDocBlock). A read inlines a reference block the way it inlines a token-ref, so npx @astryxdesign/cli docs, in text and in --json, returns only the stable block kinds. Docs-graph-only workflow and collection blocks are exported separately as GraphContentBlock and accepted by NamespaceDoc. choice, callout, and checklist remain invalid. ReferenceContentBlock is also reused by the notes field on SchemaDoc and CommandDoc. Inside text, {@link <target>} links another doc by identity ([<provider>:]<kind>:<name>): the CLI prints the command that opens it, and npx @astryxdesign/cli doctor warns on one that names no doc. Write a CLI command in text, shell code, or a table cell as a bare npx @astryxdesign/cli command: a text read adds the runner the reader uses in front of it, and --json returns it as written.

A reference block includes content, where a link only points at it: npx @astryxdesign/cli doctor integration docs fails when its target names no doc, when projection.fields names a field the schema does not have, or when the doc cannot take the projection or presentation it sets, and a read marks the missing content. A target without a provider names a doc of the package that wrote it, so an integration names the CLI's docs with the CLI's provider, as in @astryxdesign/cli:schema:integration.

A section that includes two fields of the integration manifest
js
{
title: 'Point the manifest at your folders',
content: [
{type: 'prose', text: 'Add these fields to astryx.integration.mjs.'},
{
type: 'reference',
target: '@astryxdesign/cli:schema:integration',
projection: {fields: ['components', 'docs']},
},
],
}
ReferenceContentBlock union
ts
type ReferenceContentBlock =
| { type: 'prose'; text: string }
| { type: 'heading'; level: 3 | 4 | 5 | 6; text: string }
| { type: 'code'; lang: string; code: string; label?: string }
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'list'; style: 'ordered' | 'unordered' | 'do' | 'dont'; items: string[] }
| { type: 'token-ref'; topic: string; section: string };
​
type ReferenceDocBlock = {
type: 'reference';
target: string;
projection?: { fields?: string[]; sections?: string[] };
presentation?: 'summary' | 'compact' | 'full';
};
​
type GraphContentBlock =
| { type: 'workflow'; title?: string; steps: WorkflowStep[] }
| { type: 'collection'; title?: string; source: {slot: string}; presentation?: 'list' | 'cards' | 'compact'; whenEmpty?: 'show' | 'omit' }
| ReferenceDocBlock;

A token-ref block (e.g. {type: 'token-ref', topic: 'tokens', section: 'Color Tokens'}) references a token table in another topic; the CLI resolves and inlines it at read time so the docsite can render live theme values.

A section may set previewType, a hint for previewing its token table, to one of 'swatch' | 'shadow-box' | 'radius-box' | 'spacing-bar' | 'size-bar' | 'border-line' | 'duration-bar' | 'easing-curve' | 'font-sample'; the CLI rejects any other value.

SchemaDoc

The doc-type for documenting an authored/received OBJECT shape (astryx.config, a codemod payload, the doc-types themselves). Colocated as a .doc.mjs next to the schema it describes. Fields nest recursively, so a whole shape is one tree.

Applies to: <schema>.doc.mjs

FieldTypeRequiredDescription
type'schema'noDoc-kind discriminant. Marks the file as a schema doc.
namestringyesURL-safe identifier, used as the docs slug within its namespace. Example: 'config'.
displayNamestringyesHuman-readable title. Example: 'Astryx Config'.
descriptionstringyesOne-line summary shown in listings.
namespacestringnoThe group that reads this doc: 'authoring' for a file an author writes (a section of npx @astryxdesign/cli docs authoring/field-reference), or 'cli/api' for a shape the CLI returns (the docs tree adopts it by kind, as the leaf cli/api/schemas/<name>). Every schema doc the CLI ships declares one, and npx @astryxdesign/cli doctor fails on one that is missing or that nothing reads.
aliasesstring[]noReserved: alternate slugs for this doc. Nothing reads it yet: npx @astryxdesign/cli docs finds a schema doc by its name only.
appliesTostringnoWhat this schema applies to, e.g. 'astryx.config.{ts,mjs,js}' | 'AstryxConfig'.
fieldsSchemaFieldDoc[]yesThe fields that make up the shape. Object-typed fields nest their members recursively via each field's own fields, so the whole shape is one tree.
fields[].namestringyesField name, or a dotted path for a nested field (e.g. 'hooks.postCodemod').
fields[].typestringyesTypeScript type signature as a string, e.g. 'string[]' | "'a' | 'b'".
fields[].descriptionstringyesWhat the field is for, in 1-2 sentences.
fields[].requiredbooleannoTrue if the field must be provided. Omit (don't set false) if optional.
fields[].defaultstringnoDefault value as a string, if any.
fields[].examplestringnoA short inline example value.
fields[].deprecatedstringnoDeprecation reason, if the field is deprecated.
fields[].fieldsSchemaFieldDoc[]noNested object fields, for object-typed fields. Recursive.
examples{ label?: string; code: string }[]noFull example objects/snippets.
examples[].labelstringnoOptional heading shown above the snippet.
examples[].codestringyesThe example source.
notesReferenceContentBlock[]noFreeform prose/notes rendered after the field table and examples. The stable ReferenceContentBlock union (prose, heading, code, table, list, token-ref), without the reference block a ReferenceDoc section also takes; a token-ref note is accepted but not rendered.
A small schema doc with a nested object field
js
/** @type {import('@astryxdesign/cli/authoring').SchemaDoc} */
export const doc = {
type: 'schema',
name: 'integration',
displayName: 'Astryx Integration',
namespace: 'authoring',
description: 'The astryx.integration.* manifest that points the CLI at what a package contributes.',
appliesTo: 'astryx.integration.{ts,mjs,js}',
fields: [
{name: 'docs', type: 'string', description: 'The folder that holds your doc topics, relative to package.json.'},
{
name: 'agentDocs',
type: '{ append?: readonly string[] }',
description: 'Guidance your package adds to the agent instructions the CLI manages.',
fields: [
{name: 'agentDocs.append', type: 'readonly string[]', description: 'Lines added at the end, at most eight.'},
],
},
],
};

SchemaFieldDoc is recursive: an object-typed field lists its members in its own fields, so an entire nested shape (including paths like hooks.postCodemod or experimental.xle.components) is documented as one tree.

Name nested fields with a dotted path from the root (e.g. hooks.postCodemod) so readers can see where each field sits in the shape.

GuidancePractices
Do

Set required: true only for mandatory fields.

Do

Keep type close to the real TS type; use single quotes for string-literal unions.

Don't

Set required: false for optional fields; omit required entirely instead.

TemplateDoc

The doc-type for template metadata. A discriminated union of PageTemplateDoc (type: 'page') for full page templates and BlockTemplateDoc (type: 'block') for editable compositions. A block can stand alone or use exampleFor to attach to one component; isShowcase requires that component ownership.

Applies to: <Name>.doc.mjs

FieldTypeRequiredDescription
type'page' | 'block'yesDiscriminant selecting the variant: 'page' for a full page template, 'block' for an editable composition that may be standalone or component-owned.
namestringyesStable identifier for block templates; change displayName, not name, to edit their visible label in the gallery. For page templates it is a human-readable label, while the existing template-directory/CLI slug owns the default registry path. npx @astryxdesign/cli template --list and npx @astryxdesign/cli search print name as the label of both, and npx @astryxdesign/cli template <id> finds a template by its id, never by name: an integration template's id is its path under the templates folder without the .doc.* suffix.
displayNamestringyesHuman-readable label for the gallery. The CLI prints name instead; npx @astryxdesign/cli template --list --json also returns an integration template's displayName. Spaces out block names that mirror a PascalCase component ('ChatMessageMetadata' → 'Chat Message Metadata').
registryRegistryDocIdentitynoOptional public registry identity. The converter derives a stable kebab-case slug from name (or the existing page-template slug); set slug only to override it, and keep prior relative paths in aliases after a published rename.
descriptionstringnoOne-sentence description of what the template provides.
keywordsstring[]noSearch keywords for CLI discovery: the ideas, domains, and other names a builder might use for what the template serves (e.g. ['monitoring', 'uptime', 'on-call'] for a service-health dashboard). Lowercase. npx @astryxdesign/cli search matches them as it matches the description and npx @astryxdesign/cli build ranks page templates on them, so keep them out of description. Integration templates need @astryxdesign/cli 0.6.6 or later: earlier CLIs reject the field and drop that template, and CLIs before 0.6.4 also hide the package's doc topics.
replacesstringnoIntegration templates only: the exact id of the Core template this one replaces for unqualified lookup. Find it with npx @astryxdesign/cli --json template --list --package @astryxdesign/core; the Core original stays selectable with --package @astryxdesign/core. A page replaces only a Core page and a block only a Core block. Needs @astryxdesign/cli 0.6.4 or later: earlier CLIs reject the field, drop that template, and hide the package's doc topics.
isReadybooleannoWhether the template is ready for use. false shows as '(WIP)' in the gallery and CLI.
scaffoldbooleannoScaffolding-only template (e.g. blank page): available via the CLI but hidden from browsable galleries.
categoryTemplateCategorynoFunctional gallery category following a 'Group - Variant' convention (e.g. 'Dashboard - Analytics', 'Table - Basic', 'Form - Wizard'). The overview groups by the text before ' - '.
isHiddenFromOverviewbooleannoOpt out of the Templates overview gallery while staying available via the CLI. Use for duplicate/experimental variants. Scaffold templates are hidden automatically.
exampleForstringnoBlock templates only: optional component ownership. Set this when the block is specifically an example of one component. Omit it for a standalone composition.
alsoExampleForstring[]noBlock templates only: additional component/hook doc pages whose Examples section should include this block.
alsoShowcaseForstring[]noBlock templates only: additional doc pages whose hero showcase should reuse this block (secondary placements; does not change the primary showcase).
aspectRationumbernoBlock templates only (required): width-to-height ratio for preview containers (e.g. 16/9, 1, 3/4).
scalenumbernoBlock templates only: scale factor for the block preview. Default: 1.
componentsUsedstring[]noBlock templates only: component names this block uses, for 'See also'/'Used in' cross-references (not primary attribution).
isShowcasebooleannoBlock templates only: when true this block is the canonical hero showcase for its exampleFor component. Requires exampleFor.
Page template
js
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
export default {
type: 'page',
name: 'Dashboard',
displayName: 'Dashboard',
description: 'An analytics dashboard with KPI cards and charts.',
category: 'Dashboard - Analytics',
isReady: true,
};
Block template (component example)
js
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
export default {
type: 'block',
name: 'ButtonGroupExample',
displayName: 'Button Group Example',
description: 'A row of related buttons showing primary and secondary actions.',
exampleFor: 'Button',
aspectRatio: 16 / 9,
componentsUsed: ['Button', 'HStack'],
isShowcase: true,
};

When it loads, a template may leave out displayName and aspectRatio and use its own category, as integration templates already published do; a block with no aspectRatio previews at 1. Write to the type anyway; it is the contract, and npx @astryxdesign/cli doctor integration validate warns about an integration template that leaves out displayName or aspectRatio.

TemplateDoc is a discriminated union keyed by type. Set type: 'page' for a full page template. Set type: 'block' for an editable composition; add exampleFor only when one component owns the example.

  • PageTemplateDoc (type: 'page'): a full page template; name doubles as its display value.
  • BlockTemplateDoc (type: 'block'): an editable composition. exampleFor is optional component ownership; omit it for a standalone block. isShowcase requires exampleFor.

category uses the shared TemplateCategory taxonomy: 'Group - Variant' strings (e.g. 'Table - Bulk Actions'). Not every value maps to an existing template; unused values are reserved so authors get autocomplete for the full taxonomy.

IntegrationTemplateDoc

The doc-type for an integration package's template metadata. It has every TemplateDoc field and rule, except that category is the package's own string instead of a TemplateCategory value.

Applies to: <Name>.doc.mjs

FieldTypeRequiredDescription
categorystringnoThe package's own category. Bare npx @astryxdesign/cli template groups the package's templates by it.
Block template in an integration package
js
/** @type {import('@astryxdesign/cli/authoring').IntegrationTemplateDoc} */
export default {
type: 'block',
name: 'acme-pricing-table',
displayName: 'Pricing Table',
description: 'Plans side by side, with the chosen plan highlighted.',
aspectRatio: 16 / 9,
category: 'Acme - Commerce',
};

Every other field, and the page and block variants, are TemplateDoc's. Core's own templates use TemplateDoc, whose category is a TemplateCategory value. npx @astryxdesign/cli doctor integration validate checks an integration's template docs against IntegrationTemplateDoc.

ThemeDoc

The strongly typed descriptor for one source theme: bundled with the CLI, shipped by an integration, or kept in the project's src/themes, where theme eject writes one.

Applies to: <theme-source-stem>.doc.mjs

FieldTypeRequiredDescription
type'theme'yesDoc-kind discriminant.
namestringyesLower-kebab CLI theme identity. Must match the descriptor parent directory. Example: 'ocean'.
displayNamestringyesHuman-readable name shown by theme listings. Example: 'Ocean'.
descriptionstringyesOne-line summary shown by theme listings.
maintainedbooleanyesWhether the owning package actively maintains this theme.
A theme descriptor beside oceanTheme.ts
js
/** @type {import('@astryxdesign/cli/authoring').ThemeDoc} */
export default {
type: 'theme',
name: 'ocean',
displayName: 'Ocean',
description: 'Cool blue surfaces with crisp contrast.',
maintained: true,
};

ThemeDoc is static metadata. The descriptor holds one default-exported object of literal string and boolean fields, directly under a /** @type {import('@astryxdesign/cli/authoring').ThemeDoc} */ comment, and nothing else: without that comment, or with any other statement, it fails to load. Discovery validates it without executing the descriptor or theme source.

The descriptor and source share one stem. For oceanTheme.doc.mjs, discovery requires exactly one oceanTheme source module and a named oceanTheme runtime export.

The parent theme directory, including this descriptor, is the complete copy and pack boundary. Nested source, palettes, assets, and receipts need no second file list.