# Plugin SDK Reference > `@toolctrl/plugin-sdk` — the typed surface plugin authors compile > against. Mirrors the runtime capability set so an SDK upgrade is a > straight version bump on the author side. ## Installing The SDK lives in the host monorepo today as `workspace:*`. External plugin authors install it as a regular npm dependency: ```bash pnpm add -D @toolctrl/plugin-sdk ``` ## Top-level exports | Export | Kind | Purpose | | ----------------------------------------- | ----- | --------------------------------------------------------------------------- | | `defineHook` | fn | Identity wrapper that captures `Input`/`Output` types at the call site | | `createPluginSettingsClient` | fn | Settings iframe helper around the host `postMessage` protocol | | `HookHandler` | type | `(input, ctx) => Output \| Promise` | | `PluginContext` | type | Per-invocation context (logger, capabilities, tenant info) | | `HostApi` | type | Aggregate of optional bindings; only the declared ones are bound | | `PluginCapabilityName` | type | Closed enum of capabilities | | `PluginLogger` | type | Pino-subset (info/warn/error) | | `PluginHttpClient` | type | `http.outbound` capability surface | | `PluginStorage` | type | `storage.read` / `storage.write` capability surface | | `PluginAudit` | type | `audit` capability surface; production-available but still capability-gated | | `PluginTenantInfo` | type | Read-only tenant metadata (`tenant.info`) | | `PluginSettingsClient` | type | Settings iframe helper returned by `createPluginSettingsClient` | | `PluginSettingsClientError` | class | Host-declared settings request failure | | `PluginSettingsClientTimeoutError` | class | Settings request timeout failure | | `PluginSettingsFrameMessage` | type | Settings iframe `postMessage` request union | | `PluginSettingsFrameResultMessage` | type | Host response union for settings iframe requests | | `PluginSettingValue` | type | JSON-safe value shape accepted by per-user plugin settings | | `PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION` | const | Current settings iframe protocol version (`1`) | | `PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME` | const | Meta tag name that carries the host-generated channel id | ## Authoring shape ```ts import { defineHook, type HookHandler } from "@toolctrl/plugin-sdk"; export const handleHello: HookHandler<{ name: string }, { greeting: string }> = defineHook((input, ctx) => { ctx.log.info({ name: input.name }, "hello"); return { greeting: `Hello, ${input.name}!` }; }); ``` Every hook export resolves to a single WebAssembly function with a `(arg) -> result` shape. The runtime serialises both directions as JSON; complex types fall through the standard `JSON.stringify` lens. ## Payload ABI The foundation runtime exposes the active invocation payload as UTF-8 JSON through two raw WebAssembly imports: | Import | Signature | Purpose | | --------------------------- | --------------------- | ------------------------------------------------- | | `env.toolctrl_payload_len` | `() -> i32` | Length in bytes of the JSON payload | | `env.toolctrl_payload_byte` | `(index: i32) -> i32` | Byte value at `index`, or `0` outside the payload | The SDK exports `PLUGIN_PAYLOAD_ABI_VERSION` and `PLUGIN_PAYLOAD_ABI_IMPORTS` so toolchain bindings can assert the host contract. Higher-level AssemblyScript/Rust helpers are still follow-up work; raw WASM plugins can already decode the JSON bytes themselves. ## Settings iframe protocol When a manifest declares `ui.settings.entry`, the host renders that HTML inside a sandboxed `PluginSettingsFrame`. The iframe has an opaque origin (`sandbox="allow-scripts"`, no `allow-same-origin`) and a deny-by-default CSP, so settings UIs communicate with the host only through the versioned `postMessage` protocol exported by the SDK. Use `createPluginSettingsClient()` to read the injected channel id, send `ready`, `get`, and `set` messages to `window.parent`, and accept responses only when the message came from `window.parent` and matches the expected protocol version, plugin id, channel id, and request id: ```ts import { createPluginSettingsClient, PluginSettingsClientError, PluginSettingsClientTimeoutError } from "@toolctrl/plugin-sdk"; const settings = createPluginSettingsClient({ pluginId: "com.acme.settings", }); settings.ready(); try { const theme = await settings.get("theme"); applyTheme(theme); await settings.set("theme", "dark"); } catch (error) { if (error instanceof PluginSettingsClientError) { console.warn("setting request failed", error.code, error.message); } else if (error instanceof PluginSettingsClientTimeoutError) { console.warn("setting request timed out", error.message); } else { throw error; } } ``` The host validates `pluginId`, `channelId`, `requestId`, keys, and values. Values must be JSON-safe (`PluginSettingValue`) and stay within the host size/depth budgets. Persistent writes are CSRF-protected, tenant/user scoped, and rate-limited by the route action. ## Per-capability bindings on `ctx` Each capability bound to the invocation appears as an optional field on `ctx`. The runtime sets a field only when the manifest declared it AND the gateway has a host implementation for it. Always null-check before use: ```ts if (!ctx.http) { ctx.log.warn({}, "http capability not bound — manifest must declare http.outbound"); return { ok: false }; } await ctx.http.fetch({ url: "https://example.com", method: "GET" }); ``` This shape gives you compile-time documentation: every capability the plugin uses is visible at the top of the handler. ## Logger `ctx.log` is a structured logger. The host writes the message to its own logging pipeline (pino under the hood). The signature mirrors pino: ```ts ctx.log.info({ tenantId, recordId }, "entity created"); ctx.log.warn({ retries: 3 }, "retrying"); ctx.log.error({ err: errSerializable }, "fatal"); ``` Never log secrets — output sanitisation is best-effort, not a guarantee. ## HTTP client (`ctx.http`) Reserved SDK shape for `http.outbound`. It is not bound in the current production/foundation runtime; manifests may declare it only as a reviewed future capability, and calls fail closed until the host binding is explicitly enabled after security review. ```ts const response = await ctx.http!.fetch({ url: "https://hooks.slack.com/services/T0/B0/abc", method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "hello" }), }); ``` When enabled, the host validates the URL against `allowedHosts` from the manifest's `http.outbound` scope before issuing the request. Any other host results in a denied audit row. ## Storage (`ctx.storage`) Reserved SDK shape for `storage.read` and `storage.write`. It is not bound in the current production/foundation runtime; tenant-scoped storage remains fail-closed until the capability receives a dedicated security review. ```ts const channel = await ctx.storage!.get(`slack:${ctx.tenantId}:default-channel`); await ctx.storage!.put(`slack:${ctx.tenantId}:default-channel`, "#alerts", { ttlSeconds: 86_400, }); ``` Keys are byte-strings (no length cap today, but treat 256B as a soft budget). Values are byte-strings; encode JSON yourself if you need structured data. ## Audit (`ctx.audit`) Production-available when the plugin manifest declares `audit` and the tenant has granted that capability. Records a tenant-visible audit event: ```ts await ctx.audit.record({ action: "slack.notification-sent", target: `entity:${recordId}`, meta: { channel: "#alerts" }, }); ``` Use for state-change observability. Do not abuse as a queue — metadata payloads have a hard cap. ## Errors Throwing from a handler aborts the invocation. The runtime captures the exception, records `outcome=error` against every in-flight capability, flushes the audit batch, and marks the invocation `failed`. The plugin crash-tracker auto-disables a plugin after repeated failures. ## Versioning The SDK follows SemVer. Major bumps may add or remove capability surfaces; consult `docs/plugins/versioning.md` before pinning to a new major. The runtime advertises its host range through `minHostVersion` / `maxHostVersion` in the manifest — when in doubt, lint your manifest against the running host.