Skip to documentation

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.

  1. Scaffold the module

    A plugin is a directory under plugins/ whose index.ts default-exports a definePlugin({…}) 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
    index.ts     the manifest + setup() — every extension point it binds
    scoring.ts   PURE decisions: staleness, hotness, verdicts. No I/O, no DB.
    state.ts     typed ctx.store accessors (the ledger, the last scan, eval snapshot)
    README.md    what it does, how to install it, and where the runtime made it hard

    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/types and @/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.

  2. 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
    permissions: [
      "documents:read",   // intake.readDocument, lens/lineage reads
      "documents:write",  // the intakeDocumentTransforms contribution
      "graph:read",       // atlas.docsMentioningEntity
      "governance:read",  // warrant.staleConcepts / getCurationMeta / curationSummary
      "governance:write", // warrant.markReviewed / setAuthority
      "data:read",        // conduit.getQueryBySlug
      "data:execute",     // conduit.runNamedQuery (leaves a data_calls provenance row)
      "evals:read",       // crucible.listRuns / getRunMetrics
      "settings:write",   // ports.createAlert
    ],

    Nine capabilities, each traceable to one facade call — and visibly not graph:write or agents:execute. That absence is enforced, not documented: ctx.data.atlas.createEntity throws PluginPermissionError{ reason: "not_declared" }, and the example plugin has an acceptance test asserting exactly that.

  3. Subscribe to an event

    Bind a handler for a name you declared in subscribes. Choose the channel deliberately: queued is durable and at-least-once and is what anything that writes should use; inline is best-effort and belongs only where the payload already carries everything.

    setup(reg) — the cross-system subscriptionts
    // LENS → WARRANT + INTAKE + CRUCIBLE → PORTS.
    // Queued, because it writes: at-least-once, retried with backoff,
    // dead-lettered and replayable. An inline handler here would put five
    // facade calls on the latency path of every answer.
    reg.on(
      "answer.produced",
      async (ctx, event) => { /* … */ },
      { channel: "queued", timeoutMs: 20_000 },
    );
    
    // A re-ingested document is fresh again, so its citation counter resets.
    // document.updated is ORDERED per subject: without it a retried older
    // update could land after a newer one and reset a clock twice.
    reg.on("document.ingested", onDocumentTouched, { channel: "queued", timeoutMs: 10_000 });
    reg.on("document.updated", onDocumentTouched, {
      channel: "queued", ordered: true, timeoutMs: 10_000,
    });
    
    // INLINE on purpose, and the one place it is right: the payload already
    // carries everything, so the handler makes zero facade calls and one small
    // store write. At-most-once is acceptable here.
    reg.on("eval.run.completed", async (ctx, event) => { /* … */ },
      { channel: "inline", timeoutMs: 2_000 });

    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.
  4. Read and write across systems through ctx.data

    ctx.data is 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
    // ── WARRANT (read): which concepts are overdue for review?
    const stale = await ctx.data.warrant.staleConcepts(200);
    
    // ── INTAKE (read): the event carried IDENTIFIERS, never content. Reading the
    //    document is a separate, permission-checked, ACL-checked act — which is
    //    what stops the bus becoming a way to see answers you can't access.
    for (const docId of docIds) {
      const doc = await ctx.data.intake.readDocument(docId);
      if (doc && documentStaleness(doc.meta?.updatedAt, now, cfg.staleAfterDays).stale) {
        citedStaleDocs.push(docId);
      }
    }
    
    // ── CRUCIBLE (read): what did the last eval run measure?
    const runs = await ctx.data.crucible.listRuns(1);
    const metrics = await ctx.data.crucible.getRunMetrics(runs[0].id);
    
    // ── PORTS (write): stale AND hot is an incident, not a chore. Fans out over
    //    the workspace's EXISTING webhook endpoints — no new outbound leg.
    await ctx.data.ports.createAlert({
      kind: "plugin.freshness.stale_hot_concept",
      severity: cfg.severity,
      message: `"${concept.name}" is overdue for review and was just cited`,
      meta: { entityId, citations },
      dedupeKey: `plugin.freshness.stale_hot_concept:${entityId}`, // one open alert per concept
    });
    
    // ── PORTS (audit): always permitted, always stamped plugin:<id>.
    await ctx.data.ports.audit("plugin.freshness.stale_citation", event.subject.id, {
      citedStaleDocs, citedStaleConcepts, degraded,
    });

    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-guarded ctx.fetch, the effective ctx.permissions, and ctx.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
    async function optional<T>(ctx, what, degraded: string[], run: () => Promise<T>) {
      try { return await run(); }
      catch (error) {
        if (error instanceof PluginPermissionError) {
          degraded.push(`${what} (${error.required}: ${error.reason})`);
          return null;
        }
        throw error;
      }
    }
  5. 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 with satisfies against the real contract so a host change breaks your build instead of a workspace.

    three contributions from the examplets
    // PORTS — one definition becomes an agent tool AND an MCP tool.
    reg.tool("freshness_report", {
      description: "Report knowledge freshness for this workspace …",
      inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(10) }),
      readOnly: true,
      async execute(ctx, input) {
        const { limit } = input as { limit: number };
        return { ok: true as const, result: await buildReport(ctx, limit) };
      },
    });
    
    // PORTS — a worker queue. The host forces the name to
    // plugin.eli.freshness-sentinel.scan, so it can never collide with a core queue.
    reg.job("scan", async (ctx) => { await runScan(ctx); });
    
    // LINEAGE — a section on the document provenance page. Must never call
    // documentLineage itself: it is invoked FROM that function.
    reg.contribute("lineageSections", "freshness", (async (ctx, docId) => {
      const context = ctxOf(ctx);
      const doc = await context.data.intake.readDocument(docId);
      if (!doc && !entry) return null;   // nothing to say — keep the page clean
      return { title: "Freshness", rows: [ /* label/value pairs */ ] };
    }) satisfies LineageSectionProvider);

    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 carries partial: true) and the request still succeeds. A contained invocation also gets a budget of 200 facade calls, which is why the example batches one listDocuments instead of reading each document per concept.
  6. 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
    # pure decisions — no database, milliseconds
    yarn vitest run tests/plugins/freshness-sentinel-scoring.test.ts
    
    # the real runtime: real events, real permissions, real Postgres
    yarn vitest run tests/plugins/freshness-sentinel.test.ts
    
    # the whole plugin suite (host + example) — 208 tests
    yarn vitest run tests/plugins

    Write the negative tests, not just the happy path. The example asserts that an undeclared capability throws not_declared, that a revoked grant yields not_granted and a plugin.permission_denied audit 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.

  7. Load it

    A plugin is installed by a static import in src/server/plugins/installed.ts and nothing else — no filesystem scan, no glob, no import() of a path read from the database. That diff is what a reviewer reads, and it is exactly as consequential as merging code into src/server.

    src/server/plugins/installed.tsts
    import coreSignals from "../../../plugins/core-signals";
    import freshnessSentinel from "../../../plugins/freshness-sentinel";
    
    export const INSTALLED: readonly PluginDefinition[] = Object.freeze([
      coreSignals,
      freshnessSentinel,
    ] as PluginDefinition[]);

    Being in that list means loaded, not enabled. A core: true plugin 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.

  8. 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
    await installPlugin({
      workspaceId,
      pluginId: "eli.freshness-sentinel",
      grant: ["documents:read", "graph:read", "governance:read", "evals:read"],
      config: { staleAfterDays: 90, alertOnHotStale: true },
      granterCapabilities,   // an admin cannot grant what they do not hold
    });

    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
    curl -s -X POST "$BASE/api/mcp" \
      -H "authorization: Bearer $ELI_KEY" \
      -H "x-workspace-id: $WS" \
      -H "content-type: application/json" \
      -H "accept: application/json, text/event-stream" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
           "params":{"name":"freshness_report","arguments":{"limit":5}}}'
    the report, with governance:read withheld at installjson
    {
      "effectivePermissions": ["data:execute","data:read","documents:read",
                               "documents:write","evals:read","governance:write","graph:read"],
      "degraded": ["warrant.curationSummary (governance:read: not_granted)"],
      "governance": null
    }

    A contributed lineage section is equally visible over REST — GET /api/v1/documents/{id}/lineage returns a contributed[] array in a workspace where the plugin is installed, and omits it entirely where it is not.

No HTTP surface for install yet

Enablement, granting and configuration are per-workspace runtime operations, but there is no admin UI or REST endpoint for them 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

  1. Declare narrowly. Ask for the capabilities you use and no more. The absence is enforced, and it is the first thing a reviewer reads.
  2. Degrade, do not die. Catch exactly PluginPermissionError on optional legs and record what was lost; let real errors fail the delivery so retry can fix them.
  3. Be idempotent. Queued delivery is at-least-once. Key state on the event ULID.
  4. Keep the decision pure. Everything that decides what to do belongs in a dependency-free module that is unit-tested without a database.
  5. 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.