> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Site health (checks and findings)

> A checker that reads the draft and reports what is wrong with it — missing alt text, dead internal links, duplicate titles, translations that fell behind — as findings you can dismiss or snooze. Runs on publish, on demand, or on every edit.

## Overview

This page is for developers and self-hosters: what the checker looks for, when
it runs, and the HTTP API that reads its findings.

**Site health** is a checker over the *draft*, not the published site. It walks
every page's blocks, applies a set of rules, and writes what it finds to a
durable store, ordered by how much each problem matters.

<Note>
  The editor no longer has a Site health button or panel. Checks still run on
  every publish, and their findings are available through the
  [HTTP API](#http-api). In the editor they appear only in the Inbox, which
  belongs to the pre-alpha site ops agents and is off unless the server sets
  `SITE_OPS_AGENTS=1`.
</Note>

Most of the time the findings list should be empty, and that is the case it is
designed for. A checker with something to say every day is one you stop
reading. What is left is making the two or three things that *are* wrong
immediately actionable.

```mermaid theme={null}
flowchart TD
    trigger["Trigger<br/>publish · manual · apply · scheduled"]
    walk["walkPageFields<br/>flatten blocks into located, kind-tagged fields"]
    rules["Rules<br/>seo · a11y · content"]
    print["fingerprint(scopeKey, slug, ruleId, key)"]
    store[("Durable store<br/>findings + check_runs")]
    reconcile["Reconcile<br/>anything this run stopped emitting is fixed"]
    api["GET /checks/findings<br/>ordered by impact"]

    trigger --> walk --> rules --> print --> store --> reconcile --> api
```

## What it checks

Rules read **field metadata from the block manifest**, never block type names.
This matters to you if you register your own blocks: a rule naming `Hero` would
silently exempt every custom block on your site. A block that declares an
`image` field and an `imageAlt` field is checked exactly like a built-in one,
whatever you called it.

| Rule | Agent | Severity | What it means |
| - | - | - | - |
| `seo.title-missing` | seo | error | The page has no title at all. |
| `seo.title-length` | seo | info | Outside the 20–60 characters search results tend to show. |
| `seo.title-duplicate` | seo | warning | Another page has the same title. The finding names it. |
| `seo.description-missing` | seo | warning | Search results and link previews fall back to scraped text. |
| `seo.description-length` | seo | info | Outside 70–160 characters. |
| `seo.og-image-missing` | seo | info | The page has images but none is `meta.ogImage`, so shares get no thumbnail. |
| `seo.slug-quality` | seo | info | The slug has uppercase letters, underscores, spaces, or more than five segments. |
| `seo.h1-count` | seo | warning | No top-level heading, or more than one. |
| `seo.heading-order` | seo | info | Heading levels skip a step (h2 straight to h4). |
| `seo.thin-content` | seo | info | Too little text on the page to rank for anything. |
| `seo.internal-link-dead` | seo | warning | A link points at a slug this site does not have. |
| `content.file-link-unknown` | seo | error | A link points at a document the site does not have. Runs only when the site can list its documents. |
| `a11y.alt-missing` | a11y | warning | An image is set and its alt text is empty. |
| `content.unfinished` | content | warning | A field still holds the placeholder the block was scaffolded with. |
| `i18n.structure` | translation-drift | warning | A translated page no longer has the same blocks as its source-language page. |
| `i18n.untranslated` | translation-drift | warning | A text field on a translated page still holds the source page's words. |
| `i18n.source-newer` | translation-drift | info | The source-language page was edited after the translation was last touched. |

The three `i18n.*` rules pair each translated page with its source by URL
(`/de/pricing` with `/pricing`, or the site's declared default language) and
pair blocks by type and order. A site with one language gets no findings from
them.

Rules are grouped by **agent** (`seo`, `a11y`, `content`, `translation-drift`)
because reconciliation is per agent: a run of only the SEO rules must not mark
this morning's accessibility findings fixed without having looked at them.

Findings come back ordered by **impact**: severity multiplied by how much the
page matters. Without analytics, how much a page matters is read from the site
itself — how many of its pages link to it, whether its navigation names it, and
how far it is from the home page. A dead link on the home page outranks the same
problem on a page nothing links to.

## When it runs

| Trigger | Default | Control |
| - | - | - |
| `on_publish` | **on** | `CHECKS_ON_PUBLISH=0` to disable |
| `manual` | on | `POST /checks/run` |
| `on_apply` | **off** | `CHECKS_ON_APPLY=1` to enable (debounced 2s). Follows `SITE_OPS_AGENTS` when unset; `CHECKS_ON_APPLY=0` keeps it off either way |

Both automatic triggers run in the standalone orchestrator and in library mode
(`createOrchestrator()`, since 0.25.0). A run is background work started after
the response that triggered it, so it never fails a publish or an edit. On a
serverless host, pass `createOrchestrator({ waitUntil })` — Next's `after()` or
Vercel's `waitUntil` — so the platform does not stop the run when the response
is sent.

The draft tier is a few milliseconds of in-memory work and costs nothing, which
is why publish is on by default — the moment content ships is when anybody
cares whether it is broken.

`on_apply` fires on *every edit* — chat turns and `/ops` calls — so it is
opt-in. An ambient linter should be
something you turn on having decided to, not something you discover in a CPU
graph.

<Note>
  Both automatic triggers are inert under `NODE_ENV=test`, so a test suite never
  has a background task writing findings into a store its assertions are reading.
</Note>

## Findings

A finding is the identity of a *problem*, not of an occurrence of it. Its
fingerprint is `sha256(scopeKey, slug, ruleId, key)` and deliberately excludes
the offending **value** — include the value and half-fixing a title produces a
second finding instead of an updated one, orphaning the first and silently
voiding the dismissal somebody made last week.

Fingerprints are unique per scope, never globally: one site's findings are
invisible to another, and a finding id from one session cannot be acted on from
another.

### Status

| Status | Set by | Means |
| - | - | - |
| `open` | a run | A live problem. |
| `fixed` | reconciliation | A run looked and the problem was gone. |
| `snoozed` | the user | Not this week. Reopens after **7 days**. |
| `dismissed` | the user | Never show me this again. |

`fixed` is reconciliation's word, and a client cannot assert it — that would put
a finding into a state the next run immediately contradicts.

Reconciliation is the whole trick: a finding that a run stops emitting is
closed, so nothing has to *notice* that you fixed something. It is bounded to
the pages the run actually scanned, so a run over two pages never closes
findings on the other forty-three.

Snoozed findings are reconciled too. A snooze postpones the *report*, not the
problem — otherwise something you deferred on Monday and fixed on Tuesday comes
back on Friday as an open finding about a page that is fine.

<Note>
  There is no scheduler in the orchestrator process. A snooze expires on the next
  read of the findings list, which is the only moment its expiry is observable.
</Note>

## Durability

Findings live in the same SQLite file as session state, in their own
row-addressed tables (`findings`, `check_runs`) rather than in the
snapshot-rewritten ones. They survive a restart, and so do dismissals — a
checker whose dismissals evaporate is a checker people turn off in week two.

If SQLite is unavailable, the store falls back to memory and keeps working, and
every checks response carries a `durable` flag saying so. Read it: findings
written to the fallback look identical to durable ones until the process
restarts, and a background run is exactly when nobody is watching a log.

## HTTP API

Available in both the standalone orchestrator and library mode
(`createOrchestrator()`) — the logic lives in transport-agnostic actions, so a
findings panel is never empty in one and populated in the other.

<ParamField path="POST /checks/run" type="endpoint">
  Scan the session's draft and reconcile. Body: `session`, `siteId`, optional
  `slugs` (restrict the scan — cross-page rules still see the whole site), and
  `trigger`. Returns the run record, the open findings, and `durable`.
</ParamField>

<ParamField path="Each finding" type="object">
  Carries its `ruleId`, `agent`, `severity`, `impact`, `status`, a `title` and
  `detail`, and `evidence` naming the page, the block (with the block's own
  heading in `blockLabel`, which is what tells three Card Grids apart) and the
  field path. Some carry `proposedOps`, the operations that would fix them.
</ParamField>

<ParamField path="GET /checks/findings" type="endpoint">
  What is currently wrong. Query: `session`, `siteId`, optional `slug`, `agent`,
  `status` (comma-separated; defaults to `open`), `limit`.
</ParamField>

<ParamField path="GET /checks/runs" type="endpoint">
  The ledger: every run with what it opened, what it closed, and what it cost.
</ParamField>

<ParamField path="POST /checks/findings/status" type="endpoint">
  Dismiss, snooze (for seven days), or reopen one finding. Body: `session`,
  `siteId`, `id`, `status` (`dismissed` | `snoozed` | `open`). A finding
  belonging to another scope returns 404.
</ParamField>

## Extending it

A rule is a small object with an `id`, an `agent`, a `severity`, and a `run`
that receives the page, its flattened fields, and a view of the rest of the
site. Add yours to `DRAFT_RULES` in
`packages/orchestrator-core/src/checks/rules-draft.ts`.

Two things to get right:

* **Read field kinds, not block types.** `ctx.fields` gives you every field with
  its `kind` (`text`, `richtext`, `url`, `image`, `imageAlt`, `enum`, `color`,
  `number`, `boolean`, `headingLevel`), its editable path, and its container.
  Naming a block type exempts every custom block.
* **Make keys unique per block, not per page.** A rule's `key` becomes part of
  the fingerprint, and two findings from one rule sharing a key are one finding
  as far as the store is concerned. On a page with two blocks of the same type,
  a key without the block id loses the second finding.

A rule that throws costs its own findings and nothing else. A checker that goes
dark because one custom block had an unexpected prop shape is worse than one
that reports twelve of thirteen rules.

## What to read next

<CardGroup cols={2}>
  <Card title="Publishing your changes" icon="rocket" href="/editing/publish">
    The publish that wakes the checker by default.
  </Card>

  <Card title="Coverage checks" icon="clipboard-check" href="/integration/coverage">
    The other kind of number: whether the integration itself is complete.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.