docs blog library
github

Plugin lifecycle

Jazz’s plugin runtime is a ports-and-adapters feature. Core owns the ABI-facing domain contracts, non-failing, validated hook runner, consent digest, and service tags. Adapters own manifest parsing, downloads, filesystem state, dynamic import, keyring access, and module-session construction. The CLI owns local operator confirmation, and runtime wires the layers.

State machine

catalog/local manifest
  -> installed (verified bytes; never imported)
  -> trusted (exact code digest)
  -> consented (code + hooks + destinations + data classes)
  -> enabled for one agent
  -> lazily imported into that agent run's PluginSession

Every transition takes $JAZZ_HOME/plugins/.state.lock. The artifact is written and verified before one atomic state-document replacement makes it current. An incompatible state schema fails closed; repair is explicit and must not silently erase grants or rollback artifacts.

Saved current and rollback manifests pass through the install-time manifest validator on every state read. Omitted optional capabilities become empty collections before consent calculation; malformed manifests or IDs that disagree with the state entry fail closed with a plugin-state error. Reading does not rewrite the file or change trust, consent, or enablement. Existing consent grants must still match the current disclosure digest; normalization never grants consent.

The module is imported only after the current lock has valid trust, consent, and agent enablement. Installation and runtime validation share the lifecycle-event vocabulary in core/types/plugin.ts, including approval events such as permission-request. A plugin declaring those events can load alongside its completion and input handlers; unknown or duplicate events are rejected at both boundaries.

Registration is synchronous and sealed on return. Hook/provider registrations are session-local so concurrent agents cannot overwrite one another. Session disposal is best effort only: in-process ESM cannot be unloaded.

Code integrity

A trust grant names a digest, and the digest covers a directory: the installed source tree (plugins/sources/<digest>/) or the digest-addressed directory holding a packed plugin.mjs (plugins/artifacts/<digest>/). adapters/src/plugins/source-integrity.ts keeps the imported code inside that directory. The same checks run at install, in inspect/doctor (artifact valid), and in PluginModuleLoader before every import, so a copy tampered with after trust is refused:

  • The source entry goes through parsePluginSourceEntry (no absolute path, .., ., or empty segment), the recorded import path must equal the entry joined under the digest root, and the entry’s real path must be a regular file inside that root.
  • Extraction, copying (copySourceTree, O_NOFOLLOW per file), and hashing (listSourceTree) refuse symlinks and special files instead of skipping them, so the hashed tree is the imported tree.
  • scanPluginImportGraph walks static imports, re-exports, require calls, and literal dynamic imports with Bun.Transpiler.scanImports, resolves each with Bun.resolveSync as the runtime would, and refuses anything that is not a runtime built-in or a real file inside the directory. Package specifiers are refused because node_modules is outside the digest; the SDK is types-only and must be imported with import type. Imports are scanned without unused-import trimming, and JavaScript is scanned as JSX, so the scan sees a superset of what the runtime loads.

Install-time bundling was rejected for source installs: Bun.build evaluates Bun macros (with { type: "macro" }, including escaped spellings) while bundling and has no switch to turn them off, so bundling an untrusted tree would run its code before trust. scanImports parses without evaluating. Computed dynamic imports (import(variable)) are not statically visible; they are part of the reviewed source, like any other runtime behavior of trusted code.

GitHub tarballs go through fetchWithinOrigins (bounded-fetch.ts): HTTPS only, every redirect hop on api.github.com or codeload.github.com, one deadline for the whole download, and byte caps on both the compressed body and the inflated tar stream (DEFAULT_TARBALL_LIMITS). Packed manifests and artifacts use the same helper pinned to the manifest’s origin.

Advisory dispatch

route.skills runs for a fresh, top-level request after skill discovery and before the first model call. The host validates a probability distribution over the live roster plus explicit no-skill mass, then applies host policy. Plugin-produced prose is never injected.

The resulting host-rendered hint rides on the agent loop’s iteration-zero provider-only message copy. It never enters canonical messages, persistence, resume state, work state, logs, or telemetry. Disabled or absent plugins leave provider messages byte-equivalent to the previous behavior.

Promise rejection, schema failure, cooperative timeout, missing secrets, and unavailable providers all become abstention. AbortSignal and Effect interruption cannot stop synchronous loops, process.exit(), or code that ignores cancellation; the local trust disclosure must remain explicit about that limit.

Page hook dispatch

classify.page and route.snapshot are advisory hooks dispatched from browser_snapshot. The runner binds runClassifyPage and runRouteSnapshot of the run’s plugin session onto the tool context as classifyPage and routeSnapshot, the same way it binds command-risk resolution, so a tool never holds the session itself. Inputs are built from the snapshot’s role and label pairs, the page origin, and two structural booleans read from the document; no page body is sent.

validateClassifyPageOutcome rebuilds the answer from flag and probability alone and rejects an unknown or repeated flag, so a plugin cannot express a risk level, an approval decision, or a taint instruction. The page’s structural flags are merged with the plugin’s flags above the threshold, and only tighten: browser_act’s approval returns alwaysAsk with a warning Jazz words from the flag names. validateRouteSnapshotOutcome requires a full distribution over the offered refs, and the snapshot renderer only adds a lead section before the unchanged outline.

Policy dispatch

classify.command-risk runs inside the approval path after execute_command has produced its side-effect-free proposal and before Jazz decides whether the active tier covers that call. It is never dispatched for a tool with a static read-only, low-risk, or high-risk declaration. The hook receives the bounded command from the proposal’s execution arguments, not arbitrary tool state, conversation history, results, environment variables, or file contents.

The classification is evidence consumed by host policy, not an execution capability. Core validates the answer against the three ToolRiskLevel outcomes before the policy tier sees it. Tool-set membership, static risk declarations, explicit command allowlists, the selected approval tier, and the shell denylist remain host-owned controls. A classified command still follows the ordinary proposal/approval/execute pair and the denylist still runs before execution.

This hook has more authority than an advisory hook: read-only or low-risk can make an eligible command auto-approved under the active policy. Its manifest declaration and command-text data class therefore participate in the consent digest. A changed hook, destination, or data declaration invalidates consent just like a changed artifact digest.

Absent handlers and abstention, validation, timeout, provider, secret, or budget failures fall back to Jazz’s built-in command classifier. An unresolved fallback is high-risk. Batch preflight stores the validated classification by tool-call id so the per-call path does not make a second provider request after parking or concurrent approval checks.

Distribution checks

The author packer uses Bun with package bundling enabled, splitting disabled, and unresolved imports forbidden. It accepts exactly one JavaScript output and rejects native modules, WASM, emitted assets, and remaining runtime imports. Tests must load the identical digest-addressed artifact through both the development runtime and every standalone release target.

Routing component metrics live under evals/skill-routing/. The raw-request lexical scorer is a proxy, not the current Jazz product baseline. Activation requires paired end-to-end A/B evidence for actual skill loads and task success, plus latency, failure, false-positive, and cost gates on a held test split.

machine-readable: /docs/maintainers/plugin-lifecycle.md · /llms.txt · /llms-full.txt