Entry file format
The layout of a stack or fragment directory and every key, verify step field, marker and template variable in STACK.md and FRAGMENT.md.
# Entry file format
> The layout of a stack or fragment directory and every key, verify step field, marker and template variable in STACK.md and FRAGMENT.md.
A stack is a directory holding `STACK.md`, and a fragment is a directory holding `FRAGMENT.md`. Each file is YAML frontmatter the CLI reads, followed by Markdown guidance the CLI copies into the brief. This page is the exact format, version 1. For how to write a good entry, see [Write your own stack or fragment](https://stacks.steamfield.io/docs/guides/authoring.md).
## Directory layout
Every registry root (bundled, index, user or project) has the same shape:
```text title="Registry root"
stacks/
go-cli/
STACK.md
files/ # copied into every project
.gitignore
Makefile.tmpl
adapters/
claude/ # copied only when claude is a target agent
.claude/settings.json
fragments/
postgres/
FRAGMENT.md
files/
search/ # optional search cards
stacks/go-cli.json
fragments/postgres.json
```
The directory name is the entry's id, and it must match the `id` key. Files under `files/` go into every project at the same relative path. Files under `adapters/<agent>/` go in only when that agent is a target; the agents are `claude`, `codex`, `opencode` and `cursor`. Store files under their real names, dotfiles included: `files/.gitignore` is written as `.gitignore`.
An entry directory may not be or contain a symlink, and nothing past one is read. In a project registry (`./.stacks/`), the root and its `stacks/` and `fragments/` must be real directories; a symlinked one is skipped with a warning. The user and bundled roots may themselves be symlinks.
Your own entries live in the user root, `$XDG_CONFIG_HOME/stacks/` (default `~/.config/stacks/`). `stacks create` writes skeletons there. [Registries and trust](https://stacks.steamfield.io/docs/concepts/registries-and-trust.md) covers the other roots and their precedence.
## Frontmatter
The file starts with YAML between two `---` lines. Keys are snake_case, and unknown keys are errors, so a typo such as `appliesTo` fails instead of being ignored. CLI output and JSON spell the same fields in camelCase.
```yaml title="stacks/go-cli/STACK.md"
---
schema_version: 1
id: go-cli
kind: stack
name: Go CLI
description: A Go command-line tool with cobra commands, viper config (file, env, flags), ...
tags: [go, cli, make]
deps:
cli: [github.com/spf13/cobra, github.com/spf13/viper]
test: [github.com/stretchr/testify]
tools: [go, make, git, golangci-lint]
decisions:
- "Binary name: the project slug (default; the Makefile's BIN)."
- "Coverage floor: 50% (default)."
env: {}
merge: [.claude/settings.json]
fragments:
default: [agent-ops, git-hooks, ci-github]
optional: [goreleaser, rr, ce-plugin]
verify:
- { name: install, phase: setup, layer: install, run: make install }
- { name: build, layer: build, run: make build }
- { name: lint, layer: lint, run: make lint }
- { name: test, layer: test, run: make test }
commands:
install: Download modules and install dev tools that are missing
build: Build the binary into bin/ with version info
lint: Run golangci-lint
test: Unit tests
---
```
### Keys for both kinds
| Key | Type | Required | What it holds |
| --- | --- | --- | --- |
| `schema_version` | `1` | Yes | The entry format version. An entry with a newer version than the CLI reads is skipped with a warning |
| `id` | kebab-case string | Yes | Lowercase letters and digits separated by single dashes. Must match the directory name |
| `kind` | `stack` or `fragment` | Yes | Must match the file name: `STACK.md` or `FRAGMENT.md` |
| `name` | string | Yes | Display name |
| `description` | string | Yes | One-paragraph technical synopsis, shown by `list` and `show` |
| `tags` | list of strings | No | Free tags. Fragments' `applies_to` and conditional markers can match them. A stack declares exactly one task-runner tag, `make` or `scripts` |
| `deps` | map of role to package names | No | Packages by role, names only. A version in a name is an error; the agent installs current versions |
| `tools` | list of strings | No | Programs that must be on PATH. `new` and `add` list missing ones, never fail on them |
| `decisions` | list of strings | No | Choices the brief puts to you. Each must contain `(default` so `--yes` and sandbox can proceed |
| `env` | map of string to string | No | Default environment values, such as ports. `verify` uses them for keys you haven't set |
| `verify` | list of steps | No | The commands that define done. See [Verify steps](#verify-steps) |
| `commands` | map of name to description | No | Every project command and what it does. Verify steps may only call declared commands |
| `merge` | list of paths | No | JSON files to deep-merge when several entries ship the same path. See [Composition rules](#composition-rules) |
Lists default to empty and maps to `{}`.
### Stack-only keys
| Key | What it holds |
| --- | --- |
| `fragments.required` | Fragments always included. `--without` can't remove them |
| `fragments.default` | Fragments included unless left out with `--without` |
| `fragments.optional` | Fragments added only with `--with` |
### Fragment-only keys
```yaml title="fragments/postgres/FRAGMENT.md"
---
schema_version: 1
id: postgres
kind: fragment
category: service
name: PostgreSQL with SQLAlchemy for Python
description: "PostgreSQL for the FastAPI stacks (python-react, python-api): ..."
tags: [database, postgres, service]
applies_to: [python-react, python-api]
requires_tools: [docker]
tools: [docker]
deps:
backend: [sqlalchemy, asyncpg, alembic, greenlet]
env:
PORT_DB: "5432"
PORT_DB_TEST: "5433"
verify:
- { name: db-up, phase: setup, layer: install, run: make db-up }
- { name: db-migrate, phase: setup, layer: install, run: make db-migrate }
- { name: db-down, phase: teardown, layer: install, run: make db-down }
commands:
db-up: Start the database
db-migrate: Apply database migrations
db-down: Stop the development database
---
```
| Key | Type | Default | What it holds |
| --- | --- | --- | --- |
| `category` | one of `agent-ops`, `app`, `ci`, `deploy`, `release`, `service`, `vendor`, `tooling` | required | What kind of capability this adds. `app` makes the fragment an app |
| `applies_to` | list of stack ids or tags | `[]` | Stacks this fragment fits: a stack whose id or one of whose tags is listed. Empty means any stack. An app must name at least one |
| `requires` | list of fragment ids | `[]` | Fragments pulled in automatically with this one |
| `conflicts` | list of fragment ids | `[]` | Fragments that can't be in the same composition |
| `requires_tools` | list of strings | `[]` | Tools this fragment can't work without. The missing-tool warning names the `--without` flag that drops it |
| `needs_own_repo` | boolean | `false` | The fragment must sit at the root of its own git repository. `new` refuses it inside another repository |
An app is a fragment with `category: app`. A composition holds at most one app, apps get a larger word budget, and their verify steps run as a second stage after the base project passes. In sandbox, deploy, release and app fragments stay out unless named with `--with`.
## Verify steps
Each item in `verify` is one step. The manifest records the steps with every default filled in, and `stacks verify` runs them.
| Field | Type | Default | What it does |
| --- | --- | --- | --- |
| `name` | kebab-case string | required | The step's name. Unique across the whole composition |
| `run` | string | required | The shell command, run through `/bin/sh -c` |
| `phase` | `setup`, `check`, `serve` or `teardown` | `check` | When it runs. See [Verify](https://stacks.steamfield.io/docs/concepts/verify.md) for what each phase does on failure |
| `layer` | `install`, `lint`, `typecheck`, `test`, `build`, `serve`, `docs` or `check` | none | What the step proves. A missing layer is a validation warning |
| `tags` | list of strings | `[]` | Tags `--skip-tag` can filter. `prod` steps are skipped in a sandbox project unless `verify --all`. `app` is reserved for the CLI |
| `cwd` | path | project root | Working directory, which must resolve inside the project |
| `timeout` | duration such as `500ms`, `90s`, `10m` | `10m` | Applies to setup, check and teardown steps |
| `expect.http` | URL | required for `serve` | The readiness probe for a serve step. An `http` URL on `localhost`, `127.0.0.1` or `::1`; `${VAR}` expands from the step environment |
| `expect.within` | duration | `60s` | How long verify polls the probe before failing the step |
A serve step without `expect` is an error. A serve command must stay in the foreground: verify starts it, polls the URL every 500 ms until any status below 500, then stops it.
```yaml title="A serve step"
verify:
- name: dev
phase: serve
layer: serve
run: make dev
expect: { http: "http://localhost:${PORT_WEB}/", within: 60s }
```
Every stack's base composition must cover the `install`, `lint`, `typecheck` and `test` layers, plus a `serve` step with a probe when the stack is tagged `web` or `api`. [Validation checks](https://stacks.steamfield.io/docs/reference/validation.md#what-validate-checks) enforces that.
## Composition rules
When a stack and its fragments are composed, their keys combine in composition order, stack first.
| Key | How entries combine |
| --- | --- |
| `verify` | Concatenated. Two steps with the same name are a `ComposeConflict` |
| `env` | Merged. The same key with two different values is a `ComposeConflict` |
| `commands` | Merged. A later entry's description replaces an earlier one for the same name |
| `deps` | Merged per role, duplicates removed |
| `tools`, `requires_tools` | Collected, each with the entries that need it |
| `decisions` | Concatenated. A fragment's decisions are prefixed with its id in the brief |
| Shipped files | One owner per output path. Two entries shipping the same path is a `ComposeConflict` (`file_owner`), unless every writer lists the path in `merge` |
A path every writer lists in `merge` must be JSON in all of them. The CLI deep-merges the files: objects merge recursively, arrays concatenate with duplicates removed, and otherwise the later value wins. A `.tmpl` file can't be merged. No entry may write under `.git/` or `.stacks/`, in any letter case.
## The guidance body
Everything after the closing `---` is Markdown for the building agent: layout, setup, rules and pitfalls. The CLI copies it into `.stacks/BRIEF.md` under the entry's name. Use ATX headings (`## Heading`); setext underlines get a warning. A stack's body needs a `## Setup` section, and keeping its steps numbered helps the agent follow them.
Word budgets apply to the body: 2,000 words for a stack, 1,000 for a fragment, 3,000 for an app. [Validation checks](https://stacks.steamfield.io/docs/reference/validation.md#word-budgets) has the composed-brief limits.
Exact versions (`1.2.3`) and dated phrases in prose get a warning outside fenced examples, because they go stale. Dated phrases include "as of 2026", a month and year, and release-channel words such as `rc`, `alpha`, `nightly`, `canary` and `dist-tag`. Write a rule that stays true, or tell the agent how to discover the current detail.
### Conditional prose
A block between a `when` marker and an `end` marker is kept only when its condition matches the composition.
```md title="Conditional block"
<!-- stacks:when with=ci-github -->
Run the same make targets in CI that verify runs locally.
<!-- stacks:end -->
```
| Key | Matches when |
| --- | --- |
| `stack=<ids>` | The composition's stack is one of these |
| `tag=<tags>` | The stack has one of these tags |
| `tag!=<tags>` | The stack has none of these tags |
| `mode=new\|add` | The project was made by `new` or by `add` |
| `preset=default\|sandbox` | The composition uses this preset |
| `with=<ids>` | One of these fragments (or the app) is in the composition |
Commas mean OR within a key (`stack=go-cli,python-api`), and several keys on one marker mean AND. Each marker sits alone on its line, blocks don't nest, and every `when` needs an `end`. A matching block loses its marker lines; a non-matching block is removed whole.
> [!WARNING]
> Markers are found on every line of the body, inside code fences too. To show a marker as an example inside an entry, indent it or break its spelling.
A malformed marker fails composition with `EntryInvalid`. `validate` reports it as `conditional-syntax`, and warns about branches no composition can reach.
### Fill markers
`<!-- stacks:fill -->` and `<!-- stacks:fill: text -->` mark text in a shipped file the agent must replace, usually in a README. They aren't conditionals and stay as written. Pair one with a verify step that fails while the marker remains:
```yaml title="A file check"
verify:
- name: readme-filled
layer: check
run: "test -s README.md && ! grep -n 'stacks:fill' README.md"
```
## Templates
A file whose name ends in `.tmpl` is rendered and written without the suffix: `files/Makefile.tmpl` becomes `Makefile`. Every other file is copied byte for byte with its executable bit. A file named only `.tmpl` is copied unchanged.
A token is `{{` and a variable name and `}}` on one line, with optional spaces or tabs inside the braces. Anything else is literal, so `${{ secrets.TOKEN }}` in a GitHub Actions workflow passes through. There are no conditionals or loops, and these six variables are the whole set:
| Variable | Value |
| --- | --- |
| `project_name` | `--name`, else the target directory's base name |
| `project_slug` | The name as lowercase ASCII words joined by dashes, `project` when it has none |
| `env_prefix` | The slug uppercased with dashes as underscores, prefixed with `_` when it starts with a digit (`my-cli` gives `MY_CLI`) |
| `package_scope` | `--scope`, else the `package_scope` setting, without the `@`; empty when unset |
| `author` | `--author`, else the `author` setting, else `git config user.name`, with quotes, backslashes and line breaks removed |
| `year` | The current UTC year |
An unknown variable name fails `new` with `EntryInvalid` before anything is written, and `validate` warns about Actions expressions that look like tokens.
## Search cards
A search card is optional JSON beside the entry directories, at `search/stacks/<id>.json` or `search/fragments/<id>.json` (`.stacks/search/` in a project registry). It holds the words `stacks search` ranks against, plus optional plain-language copy that `show` and the directory site display. Cards don't change composition fingerprints. [Write your own stack or fragment](https://stacks.steamfield.io/docs/guides/authoring.md) covers the fields and their limits, and [How search ranks entries](https://stacks.steamfield.io/docs/reference/search.md) explains how they're used.
View as MarkdownOpen in ClaudeOpen in ChatGPTA stack is a directory holding STACK.md, and a fragment is a directory holding FRAGMENT.md. Each file is YAML frontmatter the CLI reads, followed by Markdown guidance the CLI copies into the brief. This page is the exact format, version 1. For how to write a good entry, see Write your own stack or fragment.
Directory layout
Every registry root (bundled, index, user or project) has the same shape:
stacks/
go-cli/
STACK.md
files/ # copied into every project
.gitignore
Makefile.tmpl
adapters/
claude/ # copied only when claude is a target agent
.claude/settings.json
fragments/
postgres/
FRAGMENT.md
files/
search/ # optional search cards
stacks/go-cli.json
fragments/postgres.jsonThe directory name is the entry's id, and it must match the id key. Files under files/ go into every project at the same relative path. Files under adapters/<agent>/ go in only when that agent is a target; the agents are claude, codex, opencode and cursor. Store files under their real names, dotfiles included: files/.gitignore is written as .gitignore.
An entry directory may not be or contain a symlink, and nothing past one is read. In a project registry (./.stacks/), the root and its stacks/ and fragments/ must be real directories; a symlinked one is skipped with a warning. The user and bundled roots may themselves be symlinks.
Your own entries live in the user root, $XDG_CONFIG_HOME/stacks/ (default ~/.config/stacks/). stacks create writes skeletons there. Registries and trust covers the other roots and their precedence.
Frontmatter
The file starts with YAML between two --- lines. Keys are snake_case, and unknown keys are errors, so a typo such as appliesTo fails instead of being ignored. CLI output and JSON spell the same fields in camelCase.
---
schema_version: 1
id: go-cli
kind: stack
name: Go CLI
description: A Go command-line tool with cobra commands, viper config (file, env, flags), ...
tags: [go, cli, make]
deps:
cli: [github.com/spf13/cobra, github.com/spf13/viper]
test: [github.com/stretchr/testify]
tools: [go, make, git, golangci-lint]
decisions:
- "Binary name: the project slug (default; the Makefile's BIN)."
- "Coverage floor: 50% (default)."
env: {}
merge: [.claude/settings.json]
fragments:
default: [agent-ops, git-hooks, ci-github]
optional: [goreleaser, rr, ce-plugin]
verify:
- { name: install, phase: setup, layer: install, run: make install }
- { name: build, layer: build, run: make build }
- { name: lint, layer: lint, run: make lint }
- { name: test, layer: test, run: make test }
commands:
install: Download modules and install dev tools that are missing
build: Build the binary into bin/ with version info
lint: Run golangci-lint
test: Unit tests
---Keys for both kinds
| Key | Type | Required | What it holds |
|---|---|---|---|
schema_version |
1 |
Yes | The entry format version. An entry with a newer version than the CLI reads is skipped with a warning |
id |
kebab-case string | Yes | Lowercase letters and digits separated by single dashes. Must match the directory name |
kind |
stack or fragment |
Yes | Must match the file name: STACK.md or FRAGMENT.md |
name |
string | Yes | Display name |
description |
string | Yes | One-paragraph technical synopsis, shown by list and show |
tags |
list of strings | No | Free tags. Fragments' applies_to and conditional markers can match them. A stack declares exactly one task-runner tag, make or scripts |
deps |
map of role to package names | No | Packages by role, names only. A version in a name is an error; the agent installs current versions |
tools |
list of strings | No | Programs that must be on PATH. new and add list missing ones, never fail on them |
decisions |
list of strings | No | Choices the brief puts to you. Each must contain (default so --yes and sandbox can proceed |
env |
map of string to string | No | Default environment values, such as ports. verify uses them for keys you haven't set |
verify |
list of steps | No | The commands that define done. See Verify steps |
commands |
map of name to description | No | Every project command and what it does. Verify steps may only call declared commands |
merge |
list of paths | No | JSON files to deep-merge when several entries ship the same path. See Composition rules |
Lists default to empty and maps to {}.
Stack-only keys
| Key | What it holds |
|---|---|
fragments.required |
Fragments always included. --without can't remove them |
fragments.default |
Fragments included unless left out with --without |
fragments.optional |
Fragments added only with --with |
Fragment-only keys
---
schema_version: 1
id: postgres
kind: fragment
category: service
name: PostgreSQL with SQLAlchemy for Python
description: "PostgreSQL for the FastAPI stacks (python-react, python-api): ..."
tags: [database, postgres, service]
applies_to: [python-react, python-api]
requires_tools: [docker]
tools: [docker]
deps:
backend: [sqlalchemy, asyncpg, alembic, greenlet]
env:
PORT_DB: "5432"
PORT_DB_TEST: "5433"
verify:
- { name: db-up, phase: setup, layer: install, run: make db-up }
- { name: db-migrate, phase: setup, layer: install, run: make db-migrate }
- { name: db-down, phase: teardown, layer: install, run: make db-down }
commands:
db-up: Start the database
db-migrate: Apply database migrations
db-down: Stop the development database
---| Key | Type | Default | What it holds |
|---|---|---|---|
category |
one of agent-ops, app, ci, deploy, release, service, vendor, tooling |
required | What kind of capability this adds. app makes the fragment an app |
applies_to |
list of stack ids or tags | [] |
Stacks this fragment fits: a stack whose id or one of whose tags is listed. Empty means any stack. An app must name at least one |
requires |
list of fragment ids | [] |
Fragments pulled in automatically with this one |
conflicts |
list of fragment ids | [] |
Fragments that can't be in the same composition |
requires_tools |
list of strings | [] |
Tools this fragment can't work without. The missing-tool warning names the --without flag that drops it |
needs_own_repo |
boolean | false |
The fragment must sit at the root of its own git repository. new refuses it inside another repository |
An app is a fragment with category: app. A composition holds at most one app, apps get a larger word budget, and their verify steps run as a second stage after the base project passes. In sandbox, deploy, release and app fragments stay out unless named with --with.
Verify steps
Each item in verify is one step. The manifest records the steps with every default filled in, and stacks verify runs them.
| Field | Type | Default | What it does |
|---|---|---|---|
name |
kebab-case string | required | The step's name. Unique across the whole composition |
run |
string | required | The shell command, run through /bin/sh -c |
phase |
setup, check, serve or teardown |
check |
When it runs. See Verify for what each phase does on failure |
layer |
install, lint, typecheck, test, build, serve, docs or check |
none | What the step proves. A missing layer is a validation warning |
tags |
list of strings | [] |
Tags --skip-tag can filter. prod steps are skipped in a sandbox project unless verify --all. app is reserved for the CLI |
cwd |
path | project root | Working directory, which must resolve inside the project |
timeout |
duration such as 500ms, 90s, 10m |
10m |
Applies to setup, check and teardown steps |
expect.http |
URL | required for serve |
The readiness probe for a serve step. An http URL on localhost, 127.0.0.1 or ::1; ${VAR} expands from the step environment |
expect.within |
duration | 60s |
How long verify polls the probe before failing the step |
A serve step without expect is an error. A serve command must stay in the foreground: verify starts it, polls the URL every 500 ms until any status below 500, then stops it.
verify:
- name: dev
phase: serve
layer: serve
run: make dev
expect: { http: "http://localhost:${PORT_WEB}/", within: 60s }Every stack's base composition must cover the install, lint, typecheck and test layers, plus a serve step with a probe when the stack is tagged web or api. Validation checks enforces that.
Composition rules
When a stack and its fragments are composed, their keys combine in composition order, stack first.
| Key | How entries combine |
|---|---|
verify |
Concatenated. Two steps with the same name are a ComposeConflict |
env |
Merged. The same key with two different values is a ComposeConflict |
commands |
Merged. A later entry's description replaces an earlier one for the same name |
deps |
Merged per role, duplicates removed |
tools, requires_tools |
Collected, each with the entries that need it |
decisions |
Concatenated. A fragment's decisions are prefixed with its id in the brief |
| Shipped files | One owner per output path. Two entries shipping the same path is a ComposeConflict (file_owner), unless every writer lists the path in merge |
A path every writer lists in merge must be JSON in all of them. The CLI deep-merges the files: objects merge recursively, arrays concatenate with duplicates removed, and otherwise the later value wins. A .tmpl file can't be merged. No entry may write under .git/ or .stacks/, in any letter case.
The guidance body
Everything after the closing --- is Markdown for the building agent: layout, setup, rules and pitfalls. The CLI copies it into .stacks/BRIEF.md under the entry's name. Use ATX headings (## Heading); setext underlines get a warning. A stack's body needs a ## Setup section, and keeping its steps numbered helps the agent follow them.
Word budgets apply to the body: 2,000 words for a stack, 1,000 for a fragment, 3,000 for an app. Validation checks has the composed-brief limits.
Exact versions (1.2.3) and dated phrases in prose get a warning outside fenced examples, because they go stale. Dated phrases include "as of 2026", a month and year, and release-channel words such as rc, alpha, nightly, canary and dist-tag. Write a rule that stays true, or tell the agent how to discover the current detail.
Conditional prose
A block between a when marker and an end marker is kept only when its condition matches the composition.
<!-- stacks:when with=ci-github -->
Run the same make targets in CI that verify runs locally.
<!-- stacks:end -->| Key | Matches when |
|---|---|
stack=<ids> |
The composition's stack is one of these |
tag=<tags> |
The stack has one of these tags |
tag!=<tags> |
The stack has none of these tags |
mode=new|add |
The project was made by new or by add |
preset=default|sandbox |
The composition uses this preset |
with=<ids> |
One of these fragments (or the app) is in the composition |
Commas mean OR within a key (stack=go-cli,python-api), and several keys on one marker mean AND. Each marker sits alone on its line, blocks don't nest, and every when needs an end. A matching block loses its marker lines; a non-matching block is removed whole.
Warning
Markers are found on every line of the body, inside code fences too. To show a marker as an example inside an entry, indent it or break its spelling.
A malformed marker fails composition with EntryInvalid. validate reports it as conditional-syntax, and warns about branches no composition can reach.
Fill markers
<!-- stacks:fill --> and <!-- stacks:fill: text --> mark text in a shipped file the agent must replace, usually in a README. They aren't conditionals and stay as written. Pair one with a verify step that fails while the marker remains:
verify:
- name: readme-filled
layer: check
run: "test -s README.md && ! grep -n 'stacks:fill' README.md"Templates
A file whose name ends in .tmpl is rendered and written without the suffix: files/Makefile.tmpl becomes Makefile. Every other file is copied byte for byte with its executable bit. A file named only .tmpl is copied unchanged.
A token is {{ and a variable name and }} on one line, with optional spaces or tabs inside the braces. Anything else is literal, so ${{ secrets.TOKEN }} in a GitHub Actions workflow passes through. There are no conditionals or loops, and these six variables are the whole set:
| Variable | Value |
|---|---|
project_name |
--name, else the target directory's base name |
project_slug |
The name as lowercase ASCII words joined by dashes, project when it has none |
env_prefix |
The slug uppercased with dashes as underscores, prefixed with _ when it starts with a digit (my-cli gives MY_CLI) |
package_scope |
--scope, else the package_scope setting, without the @; empty when unset |
author |
--author, else the author setting, else git config user.name, with quotes, backslashes and line breaks removed |
year |
The current UTC year |
An unknown variable name fails new with EntryInvalid before anything is written, and validate warns about Actions expressions that look like tokens.
Search cards
A search card is optional JSON beside the entry directories, at search/stacks/<id>.json or search/fragments/<id>.json (.stacks/search/ in a project registry). It holds the words stacks search ranks against, plus optional plain-language copy that show and the directory site display. Cards don't change composition fingerprints. Write your own stack or fragment covers the fields and their limits, and How search ranks entries explains how they're used.