Saylent

Extending Saylent

One recipe per extension point - the interface, the file, and how to know you're done.

Three levels: configure without code, add one file, or change the core safely. This page covers the second level - everything below has a typed interface, lives in one file (plus a registration line), and has a test you can copy.

Add an answer engine

Interface: Ask (packages/engine/src/adapters/shared.ts) - (question: string, config: { model: string; apiKey?: string }) => Promise<AskResult>.

Where: a new file in packages/engine/src/adapters/, registered in the ADAPTERS map in packages/engine/src/adapters/index.ts.

Done-check: your adapter returns an AskResult with text and a citations array populated from whatever grounding the provider's API gives you; a test in the same style as the shipped adapters (mock the provider SDK, assert the citation extraction) passes; saylent models shows your engine's model slot resolving correctly.

Add a site check

Interface: push a DomainCheck from packages/engine/src/domainChecks.ts's registry - { check: string; status: "pass" | "warn" | "fail"; detail: string }.

Where: packages/engine/src/domainChecks.ts, alongside the existing robots/JSON-LD/meta checks.

Done-check: a unit test feeds your check a crawled page fixture and asserts the right status; saylent gate-check <domain> (or the app's site-gates section) shows your new row with a clear, one-line reason a non-technical reader understands.

Add a fix family

Interface: a diagnosis rule inside diagnose() that pushes a Fix, plus its own weight in the FIX_WEIGHTS table and a drafter task if it should produce an artifact.

Where: packages/engine/src/fixes.ts.

Done-check: diagnose() is a pure function of five input arrays - feed it a fixture (replay-diagnose.ts replays a stored run through a changed diagnose() at $0) and assert your new fix appears with the right rank; weights you add are also overridable via saylent.config thresholds.fixWeights.

Add a report block

Interface: extend the report package's typed block union (text | stat | bars | list | quote | status | pages | moves | kv) with a new case, and add its Markdown and HTML renderer.

Where: the summary composer in packages/report/src/ (the block case), the matching component under packages/report/src/components/ (HTML), and packages/report/src/render/markdown.ts (Markdown).

Done-check: your block renders in both report.html and report.md from the same composed data - no separate data path - and hides itself (returns nothing) when its input is absent, per the report's rule that a thin run shows fewer cards, never an empty one.

Add a judge rubric rule

Interface: a new numbered rule (R7, R8, ...) in the judge prompt in packages/engine/src/judge.ts, proven against the golden set.

Where: packages/engine/src/judge.ts for the rule text, fixtures/golden-judge.json for the labels that prove it.

Done-check: add or update labeled cases for the scenario your rule covers, run scripts/judge-golden.ts, and confirm agreement doesn't regress on the existing 24 entries (18 judgeable) while your new cases pass. This is a real, runnable gate - not a claim you have to take on faith.

Embed the report in your own product

Interface: ReportHostValue (packages/report/src/host.tsx) - { Link, track, faviconUrl, actions, contactEmail, methodologyUrl, refresh, subscribeRunFeed, notify }.

Where: implement it and pass it through ReportHost's context provider around the report components; staticReportHost (plain anchors, a no-op tracker, actions.available: false) is the reference implementation the CLI's static render uses - interactive controls hide themselves instead of rendering dead buttons when a capability isn't available.

Done-check: the report renders in your product with your own routing and tracking wired through, and every interactive element either works or disappears - never renders inert.

Configure without code

Interface: saylentConfigSchema (packages/engine/src/config.ts).

Where: a saylent.config.json / .js / .mjs / .ts file in your project root.

Done-check: saylent models and saylent questions reflect your overrides, and a malformed config fails loudly with the exact field that's wrong rather than silently falling back to defaults. Full field-by-field reference: Configuration.

Changing the core safely

For anything bigger than these one-file additions: ARCHITECTURE.md has the pipeline diagram, the stage-to-file map, and the data contracts. The engine is kept pure by an ESLint rule (packages/engine/** cannot import next, @supabase/*, inngest, or react), the run bundle is a versioned, additive-only format (BUNDLE_VERSION), and the test suite plus the golden gate are the safety net - both run in well under a minute with no keys, so there's no reason not to run them before every commit.

On this page