# Plugin Manifest Reference > The plugin manifest is the contract between an installable WASM > binary and the host runtime. The host parses the manifest with the > Zod schema in `packages/plugin-runtime/src/manifest.ts` and rejects > anything that does not validate — adding a field here without > updating the schema does nothing. The manifest may be TOML or JSON (whichever your toolchain emits). The runtime works against the parsed object, not the source bytes — except during signing, where the canonical form is the sorted-keys JSON projection (see `cli-reference.md#sign`). ## Top-level fields | Field | Type | Required | Notes | | --------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `schemaVersion` | `1` | yes | Bumped on breaking schema changes. Host rejects unknown versions. | | `id` | reverse-DNS string | yes | Pattern `^[a-z]+(\.[a-z0-9-]+){1,}$`. Globally unique across hosts. | | `version` | semver | yes | `MAJOR.MINOR.PATCH[-prerelease]`. Host enforces SemVer for upgrades. | | `name` | string ≤ 256 chars | yes | Display name. | | `description` | string ≤ 2048 chars | no | Free-form for the marketplace. | | `entry` | string ≤ 256 chars | yes | Path of the wasm relative to the manifest, e.g. `plugin.wasm`. | | `wasmSha256` | 64-char hex | yes | SHA-256 of the binary at signing time. Re-checked on every load. | | `signature` | 128-char hex | yes | Ed25519 signature over `canonicalManifest:wasmSha256`. | | `signedBy` | 64-char hex | yes | Raw 32-byte ed25519 public key (hex). Registered with operator. | | `allowedHosts` | array of public hostnames | no | Outbound HTTP allowlist for `http.outbound`. Empty disables HTTP. No scheme, port, localhost, IP literal, `.local`, or `.internal`. | | `capabilities` | array of capability entries | no | Capability declarations. See below. | | `hooks` | array of hook entries (≥1) | yes | At least one hook must be declared; a zero-hook plugin never runs. | | `ui.settings.entry` | relative bundle path | no | Optional settings HTML entry rendered by the host in a sandboxed `PluginSettingsFrame`. Absolute paths, URL schemes, and `..` segments are rejected. When present, the settings entry must also declare `ui.settings.sha256` or `ui.settings.integrity` so the host can verify the bundled HTML before rendering it. | Settings HTML communicates with the host through the versioned `plugin.settings.*` `postMessage` protocol documented in [`sdk-reference.md#settings-iframe-protocol`](sdk-reference.md#settings-iframe-protocol). ## Capability entries ```toml [[capabilities]] name = "http.outbound" scope = { allowedHosts = ["hooks.slack.com"] } [[capabilities]] name = "secret.read" scope = { keyPattern = "slack-webhook-.*" } ``` Each entry has: - `name` — capability name from the closed enum (see `capability-matrix.md`). - `scope` — optional, capability-specific narrowing. Permitted shape: | Field | Allowed for | Meaning | | -------------- | --------------------- | --------------------------------------------- | | `allowedHosts` | `http.outbound` | Hostname allowlist (no scheme, no port) | | `keyPattern` | `secret.read` | Regex (1-128 chars) the key must match | | `tablePrefix` | `db.read`, `db.write` | Required prefix; pattern `^[a-z][a-z0-9_]*_$` | Other keys are rejected by `.strict()`. ## Hook entries ```toml [[hooks]] name = "entity.created" export = "handleEntityCreated" ``` - `name` — host hook event name; must match an entry the host emits. - `export` — function name exported from the wasm binary that handles the hook. Both fields cap at 128 characters. ### Foundation Hook Payloads The SDK exports matching payload types from `@toolctrl/plugin-sdk` (`TypedHookHandler<"entity.updated">`, `PluginHookWorkflowExecutedPayload`, and friends). Runtime validation is strict, so handlers should rely on these field names instead of ad-hoc shapes. | Hook | Payload summary | | --- | --- | | `tenant.created` | `tenantId`, `slug`, optional `ownerEmail`, optional `plan`, `createdAt` | | `user.created` | `userId`, optional nullable `tenantId`, `email`, optional `locale`, `createdAt` | | `entity.created` / `entity.updated` / `entity.deleted` | `entityType`, `entityId`, nullable `tenantId`, optional `userId`, optional `changedFields`, `occurredAt` | | `workflow.created` / `workflow.updated` / `workflow.deleted` | nullable `tenantId`, `workflowId`, nullable `userId`, `status`, `appliesToAllTenants`, optional `changedFields`, `occurredAt`; workflow blocks, credentials, and raw input are intentionally not included. | | `workflow.executed` | `tenantId`, `workflowId`, `workflowExecutionId`, optional nullable `userId`, `type`, `status`, `durationMs`, `errorCode` (`null` for `success`, required for `error`), `appliesToAllTenants`, `occurredAt`; raw execution input, output, and error bodies are intentionally not included. | | `workflow.failed` | `tenantId`, `workflowId`, `workflowExecutionId`, optional nullable `userId`, `type`, fixed `status: "error"`, required `errorCode`, `durationMs`, `appliesToAllTenants`, `occurredAt`; raw execution input, output, and error bodies are intentionally not included. | ## Strictness The schema uses Zod's `.strict()` everywhere — unknown fields cause parse failure. This is intentional: extending the manifest is a deliberate, host-side operation, never a plugin-author hack. ## Canonical bytes (for signing) The CLI and runtime verifier both sign and verify `:` where the canonical form is the manifest minus `signature`, `signedBy`, and `wasmSha256`, projected to JSON with deeply sorted keys (see `canonicalPluginSignaturePayload` in `packages/plugin-runtime/src/signature.ts`). This means adding / removing capabilities, hooks, or any other field invalidates the signature — re-signing is required for every authoring change. ## Example (TOML) ```toml schemaVersion = 1 id = "de.acme.slack-notifier" version = "1.2.0" name = "Slack Notifier" description = "Sends tenant events to a Slack channel." entry = "plugin.wasm" wasmSha256 = "a1b2..." signature = "c3d4..." signedBy = "e5f6..." allowedHosts = ["hooks.slack.com"] [[capabilities]] name = "log" [[capabilities]] name = "secret.read" scope = { keyPattern = "slack-webhook-.*" } [[capabilities]] name = "http.outbound" scope = { allowedHosts = ["hooks.slack.com"] } [[hooks]] name = "entity.created" export = "handleEntityCreated" [[hooks]] name = "entity.updated" export = "handleEntityUpdated" ``` ## Versioning + compatibility Plugins may carry a `minHostVersion` / `maxHostVersion` constraint (see `versioning.md`). Install rejects manifests whose declared host-version range does not include the current host version.