Files
HiveGrid-Wireframes/hivegrid-cloud/docs/plugins/sdk-reference.md
T

9.2 KiB

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:

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

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:

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:

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:

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.

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.

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:

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.