gridmason docs / Host SDK

The Host SDK

@gridmason/sdk is the contract that sits between a widget and the host that mounts it. It is two things in one package on opposite sides of a single boundary:

  • The HostSDK interface — the one capability-enforcing chokepoint a host shell implements. Every widget receives a HostSDK handle, and all widget I/O (records, network, events, settings, navigation, telemetry) flows through it. There is no other sanctioned path from a widget to data.
  • The widget-side helpers — thin ergonomics a widget author imports (@gridmason/sdk/react, /vue, /vanilla) that wrap the handle 1:1. Every helper bottoms out in a handle method and adds no privileged logic, so a widget stays auditable by reading its SDK calls.

The package is ESM-only, requires Node.js >= 22, and its single runtime dependency is @gridmason/protocol (the capability grammar, page-context types, and WidgetID). react and vue are optional peer dependencies — you install only the one your widget uses, and importing @gridmason/sdk alone pulls in neither. This page documents the published 0.4.0 surface.

npm install @gridmason/sdk

Package exports

The package publishes these entry points:

Import Contents
@gridmason/sdk the HostSDK interface, its typed errors, the identity-token contract, and the framework-agnostic widget helpers
@gridmason/sdk/react React helper adapter (the reference set)
@gridmason/sdk/vue Vue 3 helper adapter
@gridmason/sdk/vanilla vanilla (no-framework) helper adapter
@gridmason/sdk/noop createNoopSDK() — dev/test no-op reference implementation
@gridmason/sdk/fixture createFixtureSDK() — no-op backed by an author fixture map
@gridmason/sdk/conformance host-conformance test kit — "a valid Gridmason host" made machine-checkable

The SDK re-exports the author-facing subset of @gridmason/protocol types (WidgetID, PageContext, Capability, the page-context grammar) from its own root, so a widget author needs no second install.


What is in the HostSDK?

The HostSDK is a single object — the handle — that a host passes to each mounted widget. It groups its surface into namespaces. Every namespace is implemented by the host and called by the widget. The engine (@gridmason/core) forwards the handle opaquely and never inspects it; this interface is the entire contract between widget and host.

import type { HostSDK } from '@gridmason/sdk';

// The shape a host implements and a widget receives:
interface HostSDK {
  readonly records: { read; query; write };
  readonly net: { fetch };
  readonly events: { emit; on };
  readonly context: PageContext;
  readonly settings: { get; update; onSchema };
  readonly nav: { open; toast };
  readonly telemetry: { error; mark };
  readonly identity: { instanceId; widgetId };
}

Two design rules run through all of it. First, every gated call is checked before transport, and a denial is a thrown/rejected typed error, never an empty result — so a widget can never infer "there is data here I can't see" from an empty array. Second, the handle is per-instance: two mounts of the same widget get distinct handles with distinct identity.instanceId, and unmounting one revokes only that one.

records — capability-gated record access

Reads and writes of host-domain records. All three methods are async (they cross the host's transport), and a call the caller's capabilities do not cover rejects with PermissionDenied before any transport happens.

import type { RecordRef, RecordData } from '@gridmason/sdk';

// Read one record by reference.
// Capability required: records.read:<scope> covering ref.recordType
const customer: RecordData = await sdk.records.read(
  { recordType: 'customer', id: 'c1' },
  { fields: ['name', 'tier'] }, // optional projection; never widens access
);

// Query records of one type.
// Capability: records.read:<scope> covering spec.recordType
const recent: RecordData[] = await sdk.records.query({
  recordType: 'order',
  where: { status: 'open' },
  limit: 20,
});

// Apply a partial update and get the updated record back.
// Capability: records.write:<scope> covering ref.recordType
const updated = await sdk.records.write(
  { recordType: 'customer', id: 'c1' },
  { tier: 'gold' },
);

A RecordRef is { recordType, id } — the host owns the recordType vocabulary (customer, team, …), and the SDK treats the record's fields as opaque JSON. A RecordData is { ref, fields }. Critically, a denied query does not resolve to [] — the whole call rejects, so an empty list always means "no matching records," never "no permission."

net — scoped network access

The only network path on the handle. There is deliberately no raw fetch and no URL entry point.

import type { ScopedRequest, ScopedResponse } from '@gridmason/sdk';

// Capability: net:<host> matching req.host
const res: ScopedResponse = await sdk.net.fetch({
  host: 'api.acme.com',   // gated by net:api.acme.com — a bare host, never a URL
  path: '/v2/sales',      // path (and query) only
  method: 'GET',          // defaults to GET
  headers: { accept: 'application/json' },
});

if (res.ok) {
  const sales = await res.json<{ items: unknown[] }>();
}

A ScopedRequest names host and path separately — never a full URL — because the scoped shape is what lets the host bind the request to a specific declared host and stamp the per-instance identity on it. The ScopedResponse is an SDK-owned, DOM-free shape (status, ok, headers, json(), text()), not the global Response — the package targets ES2022 with no DOM lib so a widget's runtime can vary.

events — the typed, namespaced cross-widget bus

Co-mounted widgets communicate through a host-mediated, same-document, in-memory bus — never a shared global.

import type { TypedTopic, Unsubscribe } from '@gridmason/sdk';

interface SaleSelected { readonly id: string }

// A topic is { ns, name }. `ns` is the capability namespace gated as events:<ns>.
const saleSelected: TypedTopic<SaleSelected> = { ns: 'acme.sales', name: 'selected' };

// Publish. Capability: events:acme.sales
sdk.events.emit(saleSelected, { id: 's1' });

// Subscribe; returns an idempotent Unsubscribe. Capability: events:acme.sales
const off: Unsubscribe = sdk.events.on(saleSelected, (sale) => {
  console.log(sale.id);
});
off(); // release; also released automatically on unmount

The payload type T on TypedTopic<T> is a compile-time phantom — bind it by annotating the topic. A widget can only emit/on a topic whose events:<ns> its capabilities cover; an emit or subscribe outside its namespaces is a typed denial, not a silent no-op, and a denied emit reaches no subscriber.

context — the page's typed context

sdk.context is a PageContext (from @gridmason/protocol): the slot values the host provides for this mount, keyed by slot name. The common case is a record-scoped page exposing the record it is showing:

// A record-ref slot the page is scoped to; pass it straight to records.read.
const ref = sdk.context.record; // a RecordRefValue: { recordType, id } | undefined

Context values are the value-side counterpart of the context grammar a page-type declares. A widget reads them off sdk.context; matching a page's context against a widget's requiresContext is a host/picker concern, not a widget one.

settings — per-instance saved props

import type { WidgetSettings, JSONSchema } from '@gridmason/sdk';

const current: WidgetSettings = sdk.settings.get();          // current saved settings
await sdk.settings.update({ title: 'Q3 pipeline' });         // persist a partial patch
sdk.settings.onSchema(mySettingsJsonSchema);                  // register a form schema

settings.get() returns a plain JSON object the host persisted via its layout store. update() persists a partial patch and resolves once saved. onSchema() registers a JSON Schema so the host can render the widget's settings form in its own design system when the widget ships no custom settings element (see Widget-side helpers). Note: the interface has no host→widget settings push — a widget observes settings changes only through its own update, which is why the reactive helpers seed once and advance through the setter.

import type { RouteRef, Notice } from '@gridmason/sdk';

sdk.nav.open({ path: '/customers/c1', params: { tab: 'orders' } }); // host route, not a URL
sdk.nav.toast({ message: 'Saved', level: 'success' });              // transient notice

The widget never touches window.location — the host owns routing, so a navigation target is expressed as a host route ({ path, params }), not a URL. A Notice carries a message and an optional level (info | success | warning | error).

telemetry — observability

import type { WidgetError } from '@gridmason/sdk';

sdk.telemetry.error({ message: 'render failed', name: 'RangeError', stack });
sdk.telemetry.mark('first-paint', 12); // a named latency measurement in ms

The bare telemetry calls are deliberately identity-free at the call site — they say what happened, not which mount. The host attributes them to this widget because the handle is per-instance. The widget-side attributeTelemetry / useTelemetry helper makes that attribution explicit (see below).

identity — this mount's identity

sdk.identity.instanceId; // unique per-mount id (string)
sdk.identity.widgetId;   // the (source, tag) widget identity

This is the public per-mount identity. The unforgeable per-instance token the transport stamps on every outbound call is deliberately not here — it lives in the host's transport closure, off any surface a widget can reach (see Limiting network queries). instanceId is the value a host's stamped binding is attributed to.

The typed error surface

A capability chokepoint fails loudly and typed. Two errors cross the boundary, both exported from the root:

import {
  PermissionDenied, InstanceGone,
  isPermissionDenied, isInstanceGone,
} from '@gridmason/sdk';

try {
  await sdk.records.read(ref);
} catch (e) {
  if (isPermissionDenied(e)) {
    // The min(user, widget) capability check failed BEFORE transport.
    // e.capability is the required-but-ungranted capability; e.instanceId the mount.
  } else if (isInstanceGone(e)) {
    // The handle is stale: the widget was unmounted and its token revoked.
  }
}
  • PermissionDenied — the call failed the capability check before transport. Thrown/rejected in place of the value, so a denied records.read rejects rather than resolving to undefined/[].
  • InstanceGone — the handle is stale (the widget was unmounted, its per-instance token revoked, a call arrived on the dead handle). Rejects rather than hanging or silently resolving.

Prefer the isPermissionDenied / isInstanceGone guards over instanceof at any boundary a widget's module may cross. Each error carries a stable string discriminant (code), and the guards test that — so a host that bundles @gridmason/sdk separately from a widget (two module copies, two class identities) does not defeat the check the way instanceof can.


How do I limit network queries?

This is the heart of the platform, and it has a precise honest answer: the SDK defines the interface and the enforcement contract; the host enforces it. The HostSDK is a TypeScript interface — a conforming host is what makes the guarantees real. So this section separates what the SDK gives you to build enforcement on from what a host must do, and documents the dashboard's reference host as the worked example of the latter.

The capability model

Every gated call requires a capability — a string of the form <api>[:<scope>] from @gridmason/protocol:

Call Required capability Example
records.read / query records.read:recordType:<type> records.read:recordType:customer
records.write records.write:recordType:<type> records.write:recordType:customer
net.fetch net:<host> net:api.acme.com
events.emit / on events:<ns> events:acme.sales

Two capability sets are in play for every call: the capabilities the user/session grants, and the capabilities the widget declared in its (signed) manifest. A call is permitted only when both sides grant it — the min(user, widget) intersection. The host trusts neither alone: a widget cannot self-grant beyond the user, and a user cannot exercise a capability the widget never declared.

Grants use scope-prefix containment: a declared capability grants a required one when the api matches exactly and the declared scope path is a prefix of the required one. So unscoped records.read grants every read, records.read:recordType grants every type, and records.read:recordType:customer grants only customer. The one definition of this rule lives in @gridmason/protocol (grantsCapability), so the client handle and the server apply the identical decision.

Per-instance scoping via the identity token

Constraining which hosts a widget reaches is only half the story — the host also needs every outbound call to arrive as that specific widget instance, not as an anonymous page script. That is the per-instance identity-token contract (exported from the root):

import {
  INSTANCE_TOKEN_HEADER,   // 'x-gridmason-instance-token'
  bindIdentityStamper,     // wires a token reader into a per-mount stamper
  stampInstanceToken,      // pure header-stamping mechanic
  toInstanceToken,         // brands a shell-minted string as an InstanceToken
  type InstanceToken,
  type InstanceTokenReader,
  type IdentityStamper,
} from '@gridmason/sdk';

The division of responsibility is strict, and it is the whole security posture:

The SDK defines what identity is stamped (InstanceToken) and where it attaches (INSTANCE_TOKEN_HEADER). The shell defines how that identity is proven — minting, signing, validating.

The SDK ships no keys, no transport crypto, and no token minting. The InstanceToken is an opaque branded string; the SDK never mints, parses, or inspects it. The shell mints one unforgeable token per mount and hands the SDK transport a closure reader (InstanceTokenReader = () => InstanceToken | undefined) over it. Three rules make that binding load-bearing:

  1. Never on the handle. The token is not a member of sdk.identity (which carries only instanceId/widgetId) and is reachable by no widget code. A widget only ever holds a handle whose transport already stamps the token below the surface.
  2. Stamped below the API, on every call. The transport reads the token and attaches it to every records/net call under INSTANCE_TOKEN_HEADER. A call carrying no valid token reaches the API as an anonymous page script — no capability-scoped access.
  3. Revoked with the instance. On unmount the reader stops yielding a token, so the stamper throws InstanceGone rather than emitting an unattributed call.

stampInstanceToken overrides any value a widget pre-seeded under that header (including a different-cased variant), so a widget can never spoof or suppress its own identity. A real host wires this once per mount:

const stamper: IdentityStamper = bindIdentityStamper(
  instanceId,
  () => (revoked ? undefined : token), // the closure reader; token never returned
);

// net channel — stamp the scoped request the widget passed:
const outbound = stamper.stampRequest(req);   // req.headers now carry the token

// records channel — stamp the host's own records-transport headers:
const headers = stamper.stampHeaders(baseHeaders);

Both methods read the token afresh (so a mid-life revocation takes effect immediately) and throw InstanceGone when the reader yields undefined. Neither ever exposes the token — only stamped headers/requests leave the stamper.

Honest scope note: same-document JavaScript is not a sandbox. This binding is enforcement plumbing plus an audit trail; the hard boundary remains review of signed widget code. The token contract makes calls attributable and revocable, and combines with server-side re-checks (below) to make a stolen or forged token useless — it does not, by itself, sandbox a malicious widget in the page.

What the SDK enforces vs. what the host must enforce

This is the line the owner asked to be drawn precisely:

  • The SDK enforces nothing at runtime on its own. It is an interface plus pure, non-privileged mechanics (stampInstanceToken, bindIdentityStamper) and the typed error types. The dev handles createNoopSDK (no enforcement — nothing is denied) and createFixtureSDK (enforces the capability check but binds no remote identity and mediates no real transport) are the only shipped implementations, and both are branded so they can never be mistaken for a conforming host.
  • The host enforces the six contract rules. Whether an implementation is a conforming host is decided mechanically by the conformance kit (next section). Those rules are: (1) min(user, widget) before transport, typed denial, no empty-result leakage; (2) net.fetch reaches only declared hosts; (3) every outbound call carries the per-instance identity binding; (4) typed, namespaced, capability-gated events over a host-mediated bus; (5) per-instance handles; (6) unmount revocation with auto-unsubscribe and typed InstanceGone.

The reference enforcement pattern (the dashboard host)

The gridmason/dashboard repo is the reference conforming host, and it is worth reading as the honest example of how a host constrains a widget's network reach in a browser. It layers three constraints:

1. The enforcing handle runs the gate before transport. The reference HostSDK checks min(user, widget) on every records/net/events call, denies with a typed PermissionDenied, and — for allowed calls — stamps the per-instance identity before handing the call to an injected transport:

// reference-host.ts (dashboard), condensed
async fetch(req: ScopedRequest): Promise<ScopedResponse> {
  assertLiveAsync();                          // rule 6: stale handle → InstanceGone
  const required = netCapability(req.host);   // net:<host>
  if (!gate.allows(required)) deny(required); // rule 1: min(user, widget)
  const stamped = stamper.stampRequest(req);  // rule 3: per-instance identity
  return transport.fetch(stamped);            // rule 2: transport only sees allowed calls
}

2. The Content-Security-Policy makes the browser physically unable to reach a third-party host. The dashboard's production CSP keeps connect-src to 'self' plus its registry origins — it carries no third-party host. A net:api.acme.com widget therefore cannot open a browser connection to api.acme.com at all. Because no widget capability can widen connect-src, the policy never has to change for a widget.

3. A same-origin proxy re-checks the allowlist server-side. The widget's net.fetch is routed to a same-origin endpoint (POST /api/scoped-fetch). The browser connects to the host ('self'); the host connects out. The proxy resolves the caller's declared capabilities from the per-instance token, never from the request body, and re-checks the requested host against them with the same scope-prefix containment before making any outbound fetch:

// server/scoped-fetch (dashboard), condensed — the server-side re-check is the boundary
const declared = service.capabilities.resolve(token); // from x-gridmason-instance-token
if (declared === undefined || declared.length === 0)  // unknown token → fail closed
  return { ok: false, status: 403, error: 'no_instance_capabilities' };
if (!grantsNetHost(declared, request.host))           // host not declared → deny
  return { ok: false, status: 403, error: 'net_host_not_allowed' };
// only now forward upstream over HTTPS

The endpoint also refuses a host that is anything but a bare host (no ://, no @, no path), strips the identity and hop headers before forwarding, and fails closed (an unrecognized token resolves to no capabilities). The proof side of the token — a shell-owned Service Worker that holds the session credential in a private field page JS cannot read and attaches it to outbound API calls — is what closes the loop, so a forged x-gridmason-instance-token maps to no instance and is denied.

The takeaway for a host implementer: the SDK gives you the capability grammar, the identity-token stamping contract, and a conformance kit to prove you honored them — but the real network constraint is the combination of the pre-transport gate, a locked-down CSP, and a server-side re-check keyed on the per-instance identity. The interface alone does not constrain the network; a conforming host does.


Implementing a host

A host implements the HostSDK interface and proves it conforms. You do not need a real backend to start — the SDK ships two dev handles and a machine-checkable conformance kit.

The dev handles: createNoopSDK and createFixtureSDK

The no-op handle is the minimal viable HostSDK: every method resolves to a typed-empty default, every call is recorded for assertions, and nothing is denied. It is what the dashboard passes to widgets before a registry exists, and what a widget unit test mounts against.

import { createNoopSDK, getNoopControls } from '@gridmason/sdk/noop';

const sdk = createNoopSDK();
await sdk.records.read({ recordType: 'customer', id: 'c1' }); // → { ref, fields: {} }

// Every call is recorded — assert against it in a widget test:
const { recorder } = getNoopControls(sdk);
recorder.last('records.read')?.args[0]; // → { recordType: 'customer', id: 'c1' }

records.query resolves to [], net.fetch to an OK empty-body response, events.emit is a recorded no-op (the no-op does not deliver). Its returns are honest empties — never data behind a permission. The handle is branded: isNoopSDK(sdk) is true and getNoopControls(sdk) exposes its label, its CallRecorder, and an unmount() that drives the rule-6 lifecycle (after unmount, every gated call rejects/throws InstanceGone and every subscription is released). It would fail the conformance kit by design — the brand is how calling code refuses to mistake a dev handle for an enforcing one.

The fixture handle is the no-op backed by an author-supplied fixture map, so a widget under development receives realistic data while the capability check still runs — the point being "fixture-green predicts review-green."

import { createFixtureSDK, getFixtureControls } from '@gridmason/sdk/fixture';

const sdk = createFixtureSDK(
  {
    records: {
      read: [{ ref: { recordType: 'customer', id: 'c1' }, fields: { name: 'Acme' } }],
    },
    net: [{ match: { host: 'api.acme.com', path: '/v2/sales' }, response: { body: { items: [] } } }],
    events: [{ topic: { ns: 'acme.sales', name: 'selected' }, payload: { id: 's1' }, delay: 100 }],
  },
  { capabilities: ['records.read:recordType:customer', 'net:api.acme.com', 'events:acme.sales'] },
);

await sdk.records.read({ recordType: 'customer', id: 'c1' }); // → { ref, fields: { name: 'Acme' } }
getFixtureControls(sdk).recorder.last('records.read')?.meta;  // → { outcome: 'fixture-hit' }

It adds four behaviors over the no-op: fixture data (subset-matched, most-specific-wins), per-call outcome flagging (fixture-hit | default-empty | denied | allowed), real capability enforcement (a call the widget did not declare is denied with PermissionDenied, never satisfied by fixture data — capabilities defaults to [], so a fixture that declares nothing denies everything), and a real in-memory event bus with scripted emissions. For deterministic tests, inject createManualScheduler() and drive scripted events with scheduler.tick(ms) instead of real timers. The fixture map schema is documented separately; every field is optional and {} is a valid file where every call falls through to the no-op default.

Neither dev handle is a conforming host: the no-op enforces nothing, and the fixture enforces capabilities but binds no remote identity and mediates no real transport.

The conformance kit — runHostConformance

Passing the conformance kit is the definition of "a valid Gridmason host." In a vitest test file, hand runHostConformance a thin adapter that mounts one widget instance per scenario:

import { runHostConformance } from '@gridmason/sdk/conformance';
import { createHost } from '../src/host.js';

runHostConformance({
  name: 'my host',
  // Stand up one widget instance with the requested min(user, widget) grant and
  // return the live handle plus the two seams the interface cannot expose.
  mount: (req) => createHost(req),
});

The kit registers one vitest test per rule. Each rule that the host violates fails its own test with a ConformanceViolation describing the observed behavior. A framework-free surface (conformanceChecks, runConformanceChecks(host)) is exported for consumers embedding the checks outside vitest.

Your adapter is a ConformanceHost with one method, mount(request), returning a Mount. The kit is host-agnostic — it asserts the interface, never one implementation — so the Mount must surface the two things the handle deliberately cannot:

interface MountRequest {
  readonly widgetId: WidgetId;                                  // mounted twice for rule 5
  readonly widgetCapabilities: readonly (Capability | string)[]; // the widget-declared side
  readonly userCapabilities: readonly (Capability | string)[];   // the user side (kept narrower)
  readonly context?: PageContext;
}
interface Mount {
  readonly sdk: HostSDK;
  lastOutboundIdentity(): RemoteIdentityBinding | undefined; // rule 3: { instanceId, host? }
  unmount(): void | Promise<void>;                            // rule 6
}

The kit deliberately passes an asymmetric pair (widget-wide, user-narrow) so it can prove the host intersects rather than trusting the widget's declaration alone. It calls mount several times per rule and mounts the same widgetId twice, so your adapter must return an independent instance each call, deriving instanceId per mount.

The six contract rules the kit checks:

  1. Capability intersection before transport. A records/net call is checked against min(user, widget) before transport; a denial is a typed PermissionDenied, never an empty result (it drives both an undeclared-widget mount and a widget-wide/user-narrow mount, asserting a granted call resolves and an ungranted one — including a query — rejects rather than returning []).
  2. Net-host scope. net.fetch to a declared host resolves; to an undeclared host it rejects PermissionDenied, never a response.
  3. Per-instance remote identity. After an allowed records.read and an allowed net.fetch, lastOutboundIdentity() must return a binding whose instanceId equals the calling handle's — a host that drops the binding reports undefined and fails. The binding is token-free by design ({ instanceId, host? }); the token itself is never surfaced for observation.
  4. Typed, namespaced, capability-gated events. Emitting/subscribing outside a granted namespace is a typed denial; a granted typed topic is delivered host-mediated to a co-mounted subscriber and routed by exact topic. The check is strengthened to no delivery: a denied cross-namespace emit must throw and reach no live subscriber — never a leaked event routed by name across namespaces.
  5. Per-instance isolation. Two mounts of the same widget get distinct, non-empty instanceIds while preserving the (source, tag) widget identity.
  6. Unmount revocation. After unmount, a gated call on the stale handle rejects a typed InstanceGone (within a timeout — never hangs, never resolves), and a subscription registered through the handle receives no further emissions.

Per-instance handles and the identity-token contract

Rules 3, 5, and 6 tie together into one lifecycle. Each mount mints a public instanceId and an unforgeable InstanceToken; the handle exposes only the former. The reference host wires the SDK's bindIdentityStamper to a lifecycle so revocation and the token are one story:

import { createInstanceLifecycle } from '@gridmason/sdk/noop';
import { bindIdentityStamper } from '@gridmason/sdk';

const lifecycle = createInstanceLifecycle(instanceId);
const stamper = bindIdentityStamper(instanceId, () => (lifecycle.revoked ? undefined : token));
// ...on every outbound call: stamper.stampRequest(req) / stamper.stampHeaders(headers)
// ...on unmount: lifecycle.revoke()  → reader yields undefined → stamps throw InstanceGone,
//    and every events.on registered through lifecycle.onRevoke is released.

createInstanceLifecycle (a revocable flag plus a set of teardowns) is the exact primitive both dev handles use to make rule 6 hold mechanically, so you can reuse it in a real host. The host mints the token (e.g. 256 bits from crypto.getRandomValues, branded via toInstanceToken), holds it in the transport closure, and never exposes it — the SDK contributes only the type, the header slot, and the stamping mechanics.


Widget-side helpers

The helpers are thin, optional ergonomics over the handle — every one mirrors an sdk method 1:1 and adds no privileged logic, so a widget stays auditable by reading its SDK calls. A widget can always call the handle directly. The React adapter is the reference set; Vue and vanilla mirror it over the same framework-agnostic core, and all three share one per-handle record cache and settings source.

Framework-agnostic core (works everywhere)

These are plain functions, re-exported by every adapter, that forward straight to the handle:

import { scopedFetch, emit, subscribe, attributeTelemetry, releaseInstance } from '@gridmason/sdk';

scopedFetch(sdk, { host: 'api.acme.com', path: '/v2/sales' }); // = sdk.net.fetch(...)
emit(sdk, saleSelected, { id: 's1' });                          // = sdk.events.emit(...)
const off = subscribe(sdk, saleSelected, (s) => {});            // = sdk.events.on(...), tracked
releaseInstance(sdk);                                            // release every helper subscription

subscribe additionally registers each subscription in a per-handle registry so releaseInstance(sdk) can release them all at once (the widget-side half of rule 6). A record cache dedups reads: the first read of a given (handle, recordType, id, fields) issues one sdk.records.read; later reads of the same ref reuse it. This changes how many reads fire, never which capabilities are exercised — every distinct ref still maps to exactly one gated read, and a denial is still a rejected PermissionDenied.

React (@gridmason/sdk/react)

import type { HostSDK } from '@gridmason/sdk';
import {
  useRecord, useSettings, on, emit, scopedFetch, useTelemetry, useInstanceCleanup,
} from '@gridmason/sdk/react';

export function CustomerCard({ sdk }: { sdk: HostSDK }) {
  useInstanceCleanup(sdk); // frees every helper subscription on unmount

  // Non-throwing read state: cached, deduped, re-renders as the read resolves.
  const { data, loading, error, refetch } = useRecord(sdk, sdk.context.record);

  // Reactive per-instance settings with a persisting setter.
  const [settings, setSettings] = useSettings(sdk);

  // Effect-managed subscription; released on unmount.
  on(sdk, saleSelected, (sale) => console.log(sale.id));

  const telemetry = useTelemetry(sdk); // identity-stamped marks/errors

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Could not load this record.</p>;
  return <h2>{String(data?.fields.name ?? 'Untitled')}</h2>;
}

useRecord returns { data, error, loading, status, refetch } — a non-throwing state object (status is idle | pending | success | error). This is the shipped default because a widget is mounted into a host-owned tree it does not control and is not guaranteed to sit inside a <Suspense>/error boundary; a throwing hook without one would take down the host subtree. Pass ref as undefined (e.g. a page with no context record) for a stable idle result, so the hook is still called unconditionally. A useRecordSuspense(sdk, ref) variant is exported for widgets that do own a boundary — it shares the same cache and the same single read per ref. refetch() forces a fresh read, bypassing the cache.

useSettings returns [settings, set]; the snapshot seeds from sdk.settings.get() and advances only through set (the interface has no host→widget push). on reads the latest handler through a ref, so passing a fresh closure each render does not re-subscribe. useTelemetry returns an AttributedTelemetry facade whose mark/error stamp instanceId + widgetId before forwarding, and whose time(name, op) measures an operation's latency (including latency-to-failure) so you never compute ms by hand.

Vue (@gridmason/sdk/vue)

The same surface as Vue 3 composables, mirroring React 1:1. The idiom difference is that composables return refs the template unwraps:

<script setup lang="ts">
import { useRecord, useSettings, on, useInstanceCleanup } from '@gridmason/sdk/vue';

const { data, loading, error } = useRecord(sdk, sdk.context.record); // ComputedRefs
const [settings, setSettings] = useSettings(sdk);                    // [ComputedRef, set]
on(sdk, saleSelected, (sale) => (selectedId.value = sale.id));
useInstanceCleanup(sdk);
</script>
<template>
  <Spinner v-if="loading" />
  <ErrorCard v-else-if="error" :err="error" />
  <Card v-else :fields="data?.fields" />
</template>

Each composable registers its teardown with onScopeDispose, so a subscription is released when the owning component unmounts. useSettings returns a read-only ComputedRef (no .value setter — settings change only through set). There is no suspense variant; a widget renders its own fallbacks from loading/error. The parity test suite gates that Vue and React have the same observable behavior.

Vanilla (@gridmason/sdk/vanilla)

The non-hook form of the same surface, for framework-less widgets. Because there is no component lifecycle, subscriptions return an Unsubscribe the caller invokes itself:

import { getRecord, watchRecord, bindSettings, on, emit, scopedFetch, releaseInstance } from '@gridmason/sdk/vanilla';

// One-shot read (shares the cache/dedup):
const record = await getRecord(sdk, sdk.context.record);

// Subscribe-style read: fires immediately with the current snapshot, then on change.
const stopWatch = watchRecord(sdk, sdk.context.record, (snap) => {
  if (snap.status === 'pending') showSpinner();
  else if (snap.status === 'error') showError(snap.error);
  else render(snap.data?.fields);
});

// Imperative settings binding:
const settings = bindSettings(sdk);
const stopSettings = settings.watch((s) => renderLabel(s.label));
await settings.update({ label: 'renamed' });

// Event subscription (`on` here is the tracked `subscribe`):
const off = on(sdk, saleSelected, (sale) => {});

// Single teardown for everything this widget opened through the helpers:
releaseInstance(sdk);

getRecord, watchRecord, and bindSettings share one per-handle cache/settings source with the React and Vue adapters, so behavior does not diverge across frameworks.

The settings-form helper

For a schema-only widget — one that ships a settings JSON Schema but no custom settings element — the helper renders an editable form in the host's design system with one hook call. The SDK ships no field UI; the host supplies its design-system inputs through a SettingsFormAdapter.

import { useSettingsForm } from '@gridmason/sdk/react';
import type { SettingsFormAdapter } from '@gridmason/sdk/react';

// A STABLE schema reference (module-level constant) so registration/caching aren't defeated.
const SETTINGS_SCHEMA = {
  type: 'object',
  properties: { title: { type: 'string', title: 'Title' } },
} as const;

function Settings({ sdk }: { sdk: HostSDK }) {
  // hostAdapter renders each FieldModel to a design-system input, wiring
  // props.value → the input and props.onChange → the value round-trip.
  return <form>{useSettingsForm(sdk, SETTINGS_SCHEMA, hostAdapter)}</form>;
}

The helper owns the two things a widget author should not re-write per widget: the schema→field binding (a JSON Schema compiled into an ordered list of typed FieldModels — control kinds are text | textarea | number | checkbox | select in v0) and the value round-trip (seeding from settings.get(), persisting each edit through settings.update(), registering the schema once via settings.onSchema). The framework-agnostic contract and controller (compileSchema, settingsFormController, SettingsFormAdapter, FieldModel) are exported from the package root; the React useSettingsForm is the reference binding.

Telemetry attribution

attributeTelemetry(sdk) (or useTelemetry(sdk) in React/Vue) reads the handle's identity and stamps it onto every mark and error, so a host dashboard can aggregate per instance and per widget:

import { attributeTelemetry } from '@gridmason/sdk';

const telemetry = attributeTelemetry(sdk);
telemetry.mark('first-paint', 12);                        // → { instanceId, widgetId, name, ms }
const rows = await telemetry.time('load', () => getRecord(sdk, ref)); // times the op, marks it
telemetry.error({ message: 'render failed', name: 'RangeError' });    // folds identity into detail

This is the audit-trail side of the per-instance binding, not security enforcement — the identity is read from the handle, never minted, so a widget can only ever attribute its own mount. On a revoked handle these forwards throw a typed InstanceGone (the helper does not swallow it).

Developing without a host

To build a widget before a live host exists, mount it against a dev handle: createFixtureSDK(fixtures) for realistic data behind the same capability check, or createNoopSDK() for typed-empty defaults. Both record every call, so a widget unit test can assert exactly which SDK calls the widget made, in what order, with what arguments — see Implementing a host.