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.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 namingHero 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 issha256(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 anid, 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.fieldsgives you every field with itskind(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
keybecomes 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.
What to read next
Publishing your changes
The publish that wakes the checker by default.
Coverage checks
The other kind of number: whether the integration itself is complete.