# `.stele.yml` Reference

`.stele.yml` is **optional**. Stele works with zero configuration. Add a manifest only when defaults aren't right.

A manifest can appear at the repository root and/or in any subdirectory. All fields are optional.

## Full schema

```yaml
# Root .stele.yml — all fields optional
roots:                  # restrict indexing to these directories
  - docs/
  - artifacts/
exclude:                # glob patterns to skip (in addition to .gitignore)
  - artifacts/scratch/
  - "**/*.draft.md"
landing: docs/overview.md   # what the site root resolves to
access: internal            # optional: narrow who may open this directory (restricted | internal | invited | link)
file_browser:               # root manifest only
  enabled: true             # default: true
  path: /_browse            # default: /_browse, must start with /_
build:                      # optional build step for this directory
  command: npm run build    # any shell command
  output: dist/             # directory whose contents Stele will index
ask:                        # optional: an assistant that answers from these docs
  name: Platform Docs       # shown in the chat panel
  audience: internal        # internal (default: everyone at Axelerant) | invited (plus the viewer list) | all
  prompt: |                 # optional guidance for the assistant
    Prefer the runbook wording where two documents disagree.
  sources:                  # optional: extra filetypes to make readable
    - "**/*.likec4"
  exclude:                  # optional: paths the assistant must not read
    - internal-notes/
  renders:                  # optional: data files and the page that renders each
    technical-audit/findings.json:
      page: technical-audit/index.html
      anchor_field: id      # optional: the record field the page uses as element ids
collections: none           # optional: none | adr | runbook | finding, or a block (below)
```

```yaml
# Per-directory .stele.yml — all fields optional
roots:                  # narrow indexing within this directory
  - guides/
exclude:                # additional exclude patterns for this directory
  - drafts/
landing: getting-started.md   # this directory's landing page
access: restricted      # only the site's viewer list may open this directory (intersected with the site's access)
build:
  command: likec4 build . --output-single-file
  output: dist/
ask:                    # a corpus rooted at THIS directory, not the repo
  name: Architecture
collections:            # this directory is a collection of one built-in type
  kind: adr             # required in a block: adr | runbook | finding
  append: false         # optional: don't add the generated list below this directory's README
  status_field: state   # optional: the frontmatter field items keep their status in
theme: guide            # default | guide; this directory and below
nav:                    # optional curated sidebar for the navigation rooted here
  - group: Orientation
    pages:
      - path: index.md
        title: Overview
  - wp-repository.md    # a page path; pages left out still appear after the list
  - ../findings/        # a trailing / means that folder's landing
```

Paths in `.stele.yml` are **always relative to the directory the config lives in**.

## Field-by-field

| Field | Where | Default | Purpose |
|---|---|---|---|
| `roots` | root + per-directory | entire directory | Restrict indexing to these subdirectories, relative to this directory. Files directly in this directory are left out; nested entries (`guides/api/`) work. No absolute paths or `..`. |
| `exclude` | root + per-directory | none (plus built-ins) | Gitignore-style patterns to skip, anchored at this directory (`/scratch/` in `docs/.stele.yml` is `docs/scratch/`). Additive over the parent. |
| `landing` | root + per-directory | resolved automatically | Override landing-page resolution for this directory. |
| `access` | root + per-directory | none (the site's setting alone) | Who may open what this directory governs: `restricted` (only the site's viewer list, staff included), `internal` (everyone at Axelerant), `invited` (Axelerant plus the viewer list) or `link` (also anyone with the share link). Intersected with the site's access and every ancestor's value, so it can only narrow, never widen; a share-link viewer is refused under any value but `link`, and a request for a path under the folder is refused whether or not the page exists. Use it for a client folder in a shared repository, or an internal folder on a site shared outside Axelerant. |
| `file_browser.enabled` | root only | `true` | Enable / disable the `/_browse` file explorer. |
| `file_browser.path` | root only | `/_browse` | URL path for the file explorer. Must start with `/_`. |
| `build.command` | root + per-directory | none | Shell command to run inside this directory before indexing. |
| `build.output` | root + per-directory | none | Subdirectory whose contents replace this directory in the indexed tree. |
| `ask.name` | root + per-directory | — | Required if `ask:` is present. Shown to readers in the chat panel, so name it after the documents, not the repo. At most 80 characters. |
| `ask.audience` | root + per-directory | `internal` | Who may ask, as a set of people like the site's access and a folder's `access`. `internal` = everyone at Axelerant. `invited` = everyone at Axelerant plus the site's viewer list (signed in by emailed link); share-link-only visitors are refused. `all` = anyone who can read the site, share-link viewers included. Intersected with the site's access and every folder `access:`, so it can only narrow, and the assistant never answers from pages the asker cannot open. Anything beyond `internal` needs the site's provider approved for customer data, and every audience needs the site opt-in in the portal. |
| `ask.prompt` | root + per-directory | none | Guidance for the assistant. Concatenated ancestor-first, so a child adds to its parent's context. It cannot override the platform's citation rules. At most 2,000 characters per block and 4,000 once joined with its ancestors'; longer text is truncated with a sync warning, so put detail in a document the assistant can read. |
| `ask.sources` | root + per-directory | text filetypes | Extra glob patterns to make readable — useful for source formats the site does not publish, like `.likec4`. |
| `ask.exclude` | root + per-directory | none | Paths the assistant must not read at all. Use this the moment a corpus root contains anything you would not want quoted back. |
| `collections` | root + per-directory | none, except `docs/adr/`, `docs/runbooks/` and `docs/audits/`, which are collections of that type | Makes this directory a collection of one built-in type, so Stele lists its Markdown files as a generated table on its landing (below a Markdown landing, or as the landing when there is none). Three forms: `none` (not a collection, even at a conventional location), a type name as shorthand (`collections: adr`), or a block `{ kind, append, status_field }`. Types: `adr`, `runbook`, `finding` (audit findings, one per file). `kind` is required in a block. `append: false` keeps the list off a Markdown landing that already lists its items; an HTML/TSX/JSX landing always wins. `status_field` names the frontmatter field to read status from. Write `none`, not `false`, `null` or an empty value. A mistake inside it is a sync warning that skips the collection, never an error that fails the directory. The item frontmatter each type reads, relations and the alerts join are in `references/collections.md`. |
| `theme` | root + per-directory | `default` | How Stele draws Markdown pages and its own views here and below: `default`, or `guide` (numbered section cards, a sidebar of every page under the navigation root, prev/next). Nearest wins; a Markdown page's frontmatter `theme:` overrides it for that page. Hand-written HTML is never restyled: it opts in by linking `/_stele/themes/guide/theme.css` and `theme.js` and leaving an empty `<nav data-stele-nav></nav>`, which Stele fills with the sidebar. An unknown name is a sync warning and is ignored. |
| `nav` | root + per-directory | derived | Curates the sidebar of the navigation rooted at this directory (a directory that names a `theme` or declares `nav` starts one). A list of page paths, `{ path, title }` and `{ group, pages }`; paths relative to this directory, `dir/` for a landing, `..` allowed for a "Related" group. Listed groups come first; every page they leave out still appears after them, by folder, then frontmatter `weight`, then title. Any mistake is a sync warning and the whole list is ignored. |
| `ask.renders` | root + per-directory | none | Map of data file → the page that renders it, both relative to this directory. A value is a page path, or `{ page, anchor_field }`. Makes an answer drawn from the data file link to the page instead of the raw file. See "Where citations point". |

**Built-in ignores that always apply** (you don't need to list these): `node_modules`, `.git`, `.DS_Store`, `*.log`, hidden directories (`.foo/`) except `.stele.yml`, files larger than 25 MB, and anything matched by `.gitignore`.

## Per-directory merge rules

When `.stele.yml` files exist at multiple levels:

- **`roots`** — intersection. A child can narrow the parent's roots; it cannot widen them. It means the same at every level: only files beneath a listed directory are indexed.
- **`exclude`** — union. A child can add exclusions; it cannot remove a parent's.
- **`landing`** — each config controls only its own directory.
- **`access`** — intersection, with the site's own access setting and with every ancestor's value. A child can only narrow: `link` under a `restricted` parent is still restricted. Nothing in a repository can open a site wider than the portal set it.
- **`file_browser`** — root manifest only; ignored in subdirectory manifests.
- **`build`** — does **not** inherit. Each directory declares its own. A parent's `build` does not run for child directories.
- **`collections`** — nearest wins outright, like `ask`. A setting covers its directory and every subdirectory until a subdirectory's own `collections:` replaces it; nothing is merged key by key, and one directory holds one type. `none` turns a subtree off; a deeper setting can turn it back on.
- **`theme`** — nearest wins outright, like `collections`; a page's frontmatter `theme:` wins over it.
- **`nav`** — not merged: each directory that names a `theme` or declares `nav` starts its own sidebar, and only its own `nav` applies there.
- **`ask`** — each `ask:` declares a **corpus rooted at its own directory**. The nearest declaring ancestor wins for a given page, so a child corpus scopes to its own subtree while the parent still covers everything outside it. `prompt` concatenates ancestor-first; `audience`, `sources` and `exclude` come from the nearest declaring ancestor and are **not** merged with a parent's. Narrowing has to actually narrow, which is why a child does not inherit its parent's file set.

## What the assistant can read

An `ask:` block makes **all text under its directory readable — including files the site does not publish.** That is deliberate: a `build:` step replaces a directory's sources with its output in the indexed tree, so the sources that most need reading (a `.likec4` model, say) are exactly the ones missing from the published site.

Four ceilings always apply and cannot be configured away:

1. **The corpus root.** Nothing outside the declaring directory is ever readable.
2. **`.gitignore`.** If Git does not track it, the assistant does not see it.
3. **Hidden directories.** `.foo/` is never read.
4. **A secret denylist.** Files that look like credentials — `.env`, key material, and similar — are refused regardless of every other setting.

Two consequences worth knowing before you add a block:

- **Check what it can see.** The site's Ask settings in the portal list every readable file per corpus, flagging the ones that are readable but not published. If a corpus root contains internal notes, they will be listed there — add them to `ask.exclude`.
- **Readable is wider than citable.** The assistant may only link to URLs the site actually serves. It can use an unpublished file and will say the passage came from repository source rather than dressing it up as a published page, but it cannot link to it. The next section says how it decides where a link points.

## Where citations point

Every file the assistant can read is given, at sync time, the one URL an answer may cite it as. The assistant never builds a link itself. The rule, in order:

1. **A page you declared renders it** (`ask.renders`) — the file is cited as that page. Only honoured when the page is actually served.
2. **The file is a page of its own** (`.md`, `.html`, `.tsx`) — cited at its own URL. Markdown and HTML pages also carry their headings, so an answer can link to a section.
3. **The file is a source under a `build:` directory** — cited as that directory's page, because the rendered form of that source *is* that page.
4. **Otherwise**, the nearest folder above it with a landing page; and if there is none, the file is readable but not citable.

Three cases you will meet:

**A Markdown or HTML page.** Nothing to declare. `guides/deploy.md` cites as `/guides/deploy`, and a claim from its "Rollback" section can cite `/guides/deploy#rollback` (for HTML, only headings with an authored `id` get anchors).

**A LikeC4 (or D2) model.** The `.likec4` sources are not published — the build output replaced them — so make them readable with `ask.sources`, and they cite the diagram page automatically:

```yaml
# architecture/c4/.stele.yml
build:
  command: likec4 build . --output-single-file
  output: dist/
ask:
  name: Architecture
  sources:
    - "**/*.likec4"
```

An answer that read `architecture/c4/model.likec4` links to `/architecture/c4/`, the rendered diagram. No `renders` entry is needed; the `build:` step already says which page the sources become.

**A data file a page draws.** A JSON, CSV or YAML file the site serves is, by rule 2, a page of its own — and its URL opens the raw file. If a page renders it (a findings register, an inventory, a glossary), say so:

```yaml
# support/.stele.yml
ask:
  name: Support
  renders:
    technical-audit/findings.json:
      page: technical-audit/index.html
      anchor_field: id
```

Now a claim drawn from `findings.json` links to `/support/technical-audit/index`, the page a reader can actually read. The data file itself is still stored and read **as written** — this changes where the link goes, not what the assistant sees.

`anchor_field` is optional and does one more thing: it names the record field the page uses as each record's element id, and the assistant then gets one outline entry per record (`TA-001: API and Web have no application logging`) and can link a claim to a single record, `/support/technical-audit/index#TA-001`. Declare it only if the page really emits those ids (for instance `<article id="TA-001">`); a link to an anchor the page does not have scrolls nowhere, which is worse than a link to the page. Without it, the file cites as the whole page.

**What `anchor_field` needs from the JSON.** Record outlines and per-record anchors are computed for `.json` files only; a CSV or YAML entry in `renders` still cites as the page, but as a whole. The records are the top-level array when the document is an array of objects, otherwise the largest array of objects among the top-level keys, so `{ "meta": {...}, "data": [...] }` works and deeper nesting does not. Records need not share a structure: each is checked on its own, and one that lacks the anchor field, or holds a value that is not a string or number, is skipped without a heading or an anchor. The outline line is the anchor value followed by the first of `title`, `name`, `summary`, `label` or `description` that is a non-empty string, so a file whose records carry one of those fields reads better in `list_files`. If no record array is found the file gets no outline and cites as the whole page; if the JSON does not parse, the sync records a warning on the site's Ask card and the file stays readable. Neither case fails the sync.

Stele does not guess the rendering page from links or `fetch()` calls in the HTML — a page can link to a file without rendering it, and nothing can tell the two apart from the outside. If `renders` names a file the corpus lacks or a page the site does not serve, the sync records a warning on the site's Ask settings and the file cites by the ordinary rules.

## Common patterns

**Pin the site landing page when README.md isn't the right entrypoint:**

```yaml
# Root .stele.yml
landing: priorities/2026-q2-overview.tsx
```

**Publish only the docs and artifacts trees from a mixed repo:**

```yaml
# Root .stele.yml
roots:
  - docs/
  - artifacts/
```

**Hide drafts and scratch work everywhere:**

```yaml
# Root .stele.yml
exclude:
  - "**/scratch/"
  - "**/*.draft.md"
```

**Set a folder's landing without renaming the file to README.md:**

```yaml
# commerce/.stele.yml
landing: store-architecture-v3.tsx
```

When a new artifact supersedes the landing for its folder, update that folder's `.stele.yml` (or move the `stele.landing: true` frontmatter flag, for Markdown).

## Validation

Stele validates `.stele.yml` at sync time. A file with invalid YAML, an unknown key or a bad value **fails its directory**: nothing under it is published until it is fixed (the whole site, for the root file), and the portal lists the file's problems with line numbers on the site overview. Stele never falls back to defaults, because defaults publish more than the author meant.

The one exception is **`collections`**: an unknown type, an unknown key in its block or a bad value there is a **sync warning**. The rest of the file is used, pages publish as usual, and only that collection is skipped. A misspelt `collections` key itself still fails the file.

Common errors:

- `file_browser.path` not starting with `/_`
- Indentation or unquoted-special-character YAML errors
- A `landing` path pointing at a file that doesn't exist (or isn't a renderable type)
- `build` present without both `command` and `output`
- An `ask.renders` path that is absolute or contains `..` — both sides must stay within the directory of the `.stele.yml`
