211 lines
9.2 KiB
Markdown
211 lines
9.2 KiB
Markdown
# 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<Output>` |
|
|
| `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.
|