Skip to documentation

Extensibility

Extend or modify a plugin

Most plugin work is not writing a new one — it is changing one that already exists, or teaching the host a new place to plug into. Four layers, cheapest and safest first. Start at the top: a surprising amount of what looks like "I need to fork this" is a config field the author already exposed.

1 · Reconfigure it (no deploy)

Configuration is per workspace, validated against the plugin's configSchema on install and on every write, and it takes effect on the next invocation — no redeploy, no restart. A well-designed plugin puts every consequential choice here, defaulting the dangerous ones off.

the Freshness Sentinel's config surfacets
staleAfterDays: 90        // a document untouched this long is stale
hotCitationThreshold: 3   // citations since the last reset that make it "hot"
maxConceptsPerScan: 10    // bounded by the 200-call facade budget
sourceOfTruthQuery: null  // a governed Conduit query slug — null degrades cleanly
markReviewedOnMatch: true
downgradeAuthority: false // OFF: a plugin's first write to governance should be
                          // a deliberate admin decision, not a default
alertOnHotStale: true
autoScan: false           // OFF: it turns a read path into a queue producer

Changing the granted permission set is the other no-code lever, and it is the one to reach for when you want less behaviour rather than different behaviour. Revoke settings:writeand the sentinel stops raising alerts while every read keeps working — the loss is recorded in the plugin's degraded output and in a plugin.permission_denied audit row, so it is visible rather than mysterious.

Verify a config or grant change from outside

Call the plugin's report tool over MCP and read back effectivePermissions and degraded. That round trip is the fastest confirmation that a grant change actually landed for the workspace you meant.

2 · Change behaviour without touching the manifest

Anything that does not alter the declaration is an ordinary code change: a new scoring rule, a different threshold curve, an extra field in a report, a bug fix in a handler. Because the manifest is unchanged and the version is unchanged, installed workspaces pick it up on deploy with no reconciliation step.

Put the change in the pure decision module if you can. In the example that is plugins/freshness-sentinel/scoring.tsstalenessScore, isHot, verdictFromDocuments, downgradedAuthority — all pure functions with no I/O, which means your change is testable in milliseconds and its regression test needs no database.

Codebash
yarn vitest run tests/plugins/freshness-sentinel-scoring.test.ts   # pure, fast
yarn vitest run tests/plugins/freshness-sentinel.test.ts           # real runtime

3 · Change the manifest — and pass reconciliation

Adding a subscription, adding a contribution, or asking for a new capability all change the declaration, and the host compares the loaded manifest against what each workspace agreed to at install time. Bump version when you do, then know which bucket you land in:

ChangeOutcome per workspace
Patch or minor bump, declared permissions unchangedAuto-adopted on first use. The install advances its recorded version and keeps running.
Any widening of declared permissionsneeds_review. Subscriptions and contributions pause for that workspace until an admin re-grants. This is the point of the whole mechanism: a plugin cannot grow authority between review and deploy.
Major version bumpneeds_review, always — you said it was breaking, so the host believes you.
Downgrade (loaded version below installed)needs_review. State written by the newer version may not be readable, and the host refuses to guess.
apiVersion no longer satisfied by the hostLoad failure at boot with code api_version — loudly, before any workspace is affected.

Two mechanical rules the host enforces while you are editing. Declaration and registration must be exactly equal — binding a handler for a name absent from subscribes, or registering a contribution absent from contributes, throws PluginRegistrationError at load. And two plugins may not contribute the same name to the same extension point: that is PluginContributionCollisionError, also at load.

Renaming and removing are the sharp edges

Identity is durable in places a rename does not follow. A ctx.store key is keyed on (workspace, plugin, key), a queue is plugin.<id>.<job>, and an eval metric lands under plugin.<id>.<metric> in the run_metrics.plugin_metrics bag.
  • Rename a job and in-flight messages on the old queue have no handler. Drain first, or keep the old name registered for one release.
  • Rename a store key and the plugin silently starts from empty state — the old row is orphaned, not migrated. Migrate in onReady, which is per workspace and already async.
  • Rename or remove an eval metric and historical runs keep the old key forever. A comparison across the rename shows a metric appearing and disappearing rather than moving.
  • Change the plugin id and you have created a different plugin: new installs, new grants, new state, orphaned everything. Do not.

4 · Extend the host: a new extension point

When a plugin needs a seam that does not exist, add it to the host rather than reaching around it. The shape is fixed and small, and the consuming system gets the capability check, the containment and the deterministic ordering for free.

  1. Declare the kind

    Add the field to PluginContributions and its name to CONTRIBUTION_KINDS in src/server/plugins/types.ts, then to the contributions schema in manifest.ts so it is validated. Bump PLUGIN_HOST_API_VERSION if you changed the contract incompatibly — adding an optional field is a minor bump.

  2. Give it a timeout band

    Add an entry to TIMEOUT_BUDGETS in limits.ts with a default and a maximum. Pick the default from the latency budget of the surface it runs on, not from how long the plugin would like: a retrieval-path point gets hundreds of milliseconds, a worker-path point gets tens of seconds.

  3. Consume it from the owning system

    One call, lazily imported so the plugin runtime never enters the consuming module's import graph, and guarded so the zero-plugin case costs a synchronous map lookup.

    the pattern every consuming system usests
    const { hasContributions, invokeContributions } = await import("@/server/plugins/extend");
    if (!hasContributions("myNewKind")) return core;   // zero-plugin fast path
    
    const outcomes = await invokeContributions<MyContract, MyResult>(
      {
        kind: "myNewKind",
        workspaceId,
        budget: "myNewKind",          // the TIMEOUT_BUDGETS band
        requires: "documents:read",   // capability the point demands
        correlationId,
      },
      async (ctx, contribution) => contribution.value(ctx, someInput),
    );
    
    // The CALLER decides what a failure means, because only the caller knows
    // which degradation is honest for its surface.
    for (const outcome of outcomes) {
      if (!outcome.result.ok) { partial = true; continue; }
      merge(outcome.result.value);
    }
  4. Type the contract, and test the failures

    Export the contribution's function type from the consuming module so plugins can satisfiesit — that is what makes a host change break a plugin's build instead of a customer's workspace. Then test the three paths that matter: the plugin without the required capability is excluded and audited, a throwing contribution degrades the feature and not the request, and with zero plugins the behaviour is byte-identical to before.

Adding a core event

Add the name to PluginEventName and ALL_PLUGIN_EVENT_NAMES, then emit it from the place that already writes the durable record or the audit row — never from a second location, or the event becomes a rival source of truth. Emit with prepareEvent inside the producing transaction when the record and the event must agree, or publishEvent after it commits when best-effort is honest. Carry identifiers only: the 8KB cap is a guardrail, not the rule — the rule is that reading the thing an event names must remain a separate, permission-checked act.

Turning one off

Disableis reversible and immediate: the install stops resolving as active, so subscriptions stop being delivered and contributions stop being invoked, while the grant, the config and the plugin's state survive. Uninstall deletes the installation row and audits plugin.uninstalled. Workspace destruction purges all four plugin tables — installations, state, the event outbox and the delivery ledger — because a plugin's grant, private state and delivery history are tenant data and must leave with the tenant.

Removing a plugin from installed.ts is the deploy-time counterpart: it stops loading everywhere at once. Installation rows for it become inert rather than erroring, since the registry no longer knows the id.

Read the trust model before you extend anything

Every mechanism on this page is about correctness and blast radius, not containment of hostile code. A plugin is trusted in-process code; the control that matters is the review of the installed.ts diff. See the extension model for exactly what the in-process model does and does not protect against, and Build a plugin for the review checklist.