Extensibility
Build a plugin
This guide builds a plugin start to finish using the one that ships in the repository — plugins/freshness-sentinel/, the Knowledge Freshness Sentinel — as the worked example. Every snippet below is real code from it, not pseudocode. Read the extension model first for the contract and the trust boundary; this page is the mechanics.
The sentinel answers a question the product had every raw material for and no way to ask: which of our certified concepts have gone stale, and are we still answering customer questions out of them? It touches all eight systems — reading six, writing four — which makes it a complete tour of the surfaces you have available.
Scaffold the module
A plugin is a directory under
plugins/whoseindex.tsdefault-exports adefinePlugin({…})call. Keep the decision logic in a separate, dependency-free file so it can be unit-tested without a database, and plugin-private persistence in a third.plugins/freshness-sentinel/text Import discipline is a hard rule. A plugin module is transitively imported by API routes, the worker and the Next.js build, so its top level must not touch the network, the filesystem or optional native dependencies — side effects belong inside a handler, never at import time. Import
@/server/plugins/manifest,@/server/plugins/typesand@/server/plugins/errors— three leaf modules — plus type-only contracts for any extension point you implement. Importing@/server/db/*means you have left the model behind.Declare the manifest, and ask for less than you could
The manifest is a static declaration: the host validates it and asserts that
setup()registers exactly the names it lists. Permissions come from the existing capability union, so there is no second vocabulary to learn.plugins/freshness-sentinel/index.tsts Nine capabilities, each traceable to one facade call — and visibly not
graph:writeoragents:execute. That absence is enforced, not documented:ctx.data.atlas.createEntitythrowsPluginPermissionError{ reason: "not_declared" }, and the example plugin has an acceptance test asserting exactly that.Subscribe to an event
Bind a handler for a name you declared in
subscribes. Choose the channel deliberately:queuedis durable and at-least-once and is what anything that writes should use;inlineis best-effort and belongs only where the payload already carries everything.setup(reg) — the cross-system subscriptionts Queued delivery is at-least-once — be idempotent
Every counter in the example is keyed on the event's ULID, which is monotonic within a workspace, so a redelivery is a no-op. Read payloads defensively too: an event is a wire contract between two modules that deploy independently, and the taxonomy promises the name, not every field forever.Read and write across systems through ctx.data
ctx.datais the only data path. It is a permission-checking delegation layer over the existing server modules — never a second data layer, never raw SQL, never a transaction — so a plugin call inherits RLS, content ACLs, the read-only SQL gate, spend caps and audit for free and identically. Note what is absent from every signature:workspaceId, because the host closed over it.the answer.produced handler, abridged — six systems in one functionts Beyond the facade, the context also carries
ctx.store(plugin-private per-workspace KV, 256KB per value, no capability required),ctx.jobs.enqueue,ctx.events.emit(your own namespace only),ctx.logger, an SSRF-guardedctx.fetch, the effectivectx.permissions, andctx.signal, which aborts at the containment deadline.Degrade, do not die
Wrap optional legs so a permission denial is recorded and swallowed while every other error propagates. A missing capability is a configuration fact the operator owns — the host has already written the audit row — whereas a thrown database error should fail the delivery so at-least-once retry can fix it.Codets Contribute to an extension point
Tools, jobs and HTTP operations have dedicated registrar methods. Everything else — connectors, rerankers, candidate channels, data adapters, eval metrics, lineage sections, policy observers — goes through
reg.contribute(kind, name, value), and you should type the value withsatisfiesagainst the real contract so a host change breaks your build instead of a workspace.three contributions from the examplets Respect the budget you are running in
Each point has a timeout band — 800ms for a candidate channel, 1s for a lineage section, 2s for an eval metric or a document transform, 30s for a tool, 60s for a job. An overrun is contained: the feature degrades (the section is omitted and the response carriespartial: true) and the request still succeeds. A contained invocation also gets a budget of 200 facade calls, which is why the example batches onelistDocumentsinstead of reading each document per concept.Test it
Two layers, and the split is the point. Pure decisions are tested with no database and run in milliseconds; the runtime layer exercises real events, real permissions and real Postgres through the host's own test seams.
Codebash Write the negative tests, not just the happy path. The example asserts that an undeclared capability throws
not_declared, that a revoked grant yieldsnot_grantedand aplugin.permission_deniedaudit row while the rest of the handler keeps working, that a redelivered event is a no-op, and that a handler which throws is contained rather than escaping.Load it
A plugin is installed by a static import in
src/server/plugins/installed.tsand nothing else — no filesystem scan, no glob, noimport()of a path read from the database. That diff is what a reviewer reads, and it is exactly as consequential as merging code intosrc/server.src/server/plugins/installed.tsts Being in that list means loaded, not enabled. A
core: trueplugin auto-installs into a workspace on first use; anything else sits inert — registered, inspectable, doing nothing — until an admin installs it per workspace. Load failures are named and surface at boot:duplicate_id,bad_semver,api_version,contribution_collision.Install, grant and verify in a workspace
Installation is per workspace and carries three things: the granted capability subset (intersected with what the manifest declares, and bounded by the granting admin's own capabilities), the validated config, and enablement.
Codets Then verify from outside the process. The contributed tool appears on the MCP surface only where the plugin is installed, and its report echoes the effective permission set — which makes least privilege observable rather than merely true:
Codebash the report, with governance:read withheld at installjson A contributed lineage section is equally visible over REST —
GET /api/v1/documents/{id}/lineagereturns acontributed[]array in a workspace where the plugin is installed, and omits it entirely where it is not.
No HTTP surface for install yet
installPlugin, updatePluginConfig, setPluginEnabled and uninstallPlugin are server functions you call from a script or a server action. Everything downstream of the install (events, tools, lineage sections, jobs, audit) is fully reachable over the public API.The five rules worth copying
- Declare narrowly. Ask for the capabilities you use and no more. The absence is enforced, and it is the first thing a reviewer reads.
- Degrade, do not die. Catch exactly
PluginPermissionErroron optional legs and record what was lost; let real errors fail the delivery so retry can fix them. - Be idempotent. Queued delivery is at-least-once. Key state on the event ULID.
- Keep the decision pure. Everything that decides what to do belongs in a dependency-free module that is unit-tested without a database.
- Bound everything. Cap the documents you read, the concepts you scan and the state you write — the call budget and the 256KB value limit are real.
One anti-pattern the example deliberately avoids: it never enqueues its own job from a read path by default, and it never writes to governance by default. Both are config flags that default off, because a plugin's first write to a governed system should be a decision an admin made, not a default they inherited.
Review checklist
Because installation is a deploy-time diff rather than a sandbox, the installed.ts pull request is the control. Confirm before merging: the manifest declares no capability the code does not use; nothing at module top level touches the network, the filesystem or a native dependency; no import of @/server/db/*; handlers are idempotent and honour ctx.signal; every write path is either config-gated or obviously intended; and the negative permission tests exist. Then read the trust-model section again and decide whether you would vendor this code — because that is what installing it means.