Skip to main content

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.
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. 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.
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.

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. 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

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.
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.

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

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.
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.

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.
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.
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.
endpoint
What is currently wrong. Query: session, siteId, optional slug, agent, status (comma-separated; defaults to open), limit.
endpoint
The ledger: every run with what it opened, what it closed, and what it cost.
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.

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.

Publishing your changes

The publish that wakes the checker by default.

Coverage checks

The other kind of number: whether the integration itself is complete.