Skip to content

Gadgets SDK

read as .md

@ggui-ai/gadgets is the standard library of browser-capability hooks and the wrapper SDK that lets you bind any 3rd-party library into the same uniform contract the LLM already knows.

  • STDLIB hooksuseGeolocation, useCamera, useClipboardWrite, useClipboardPaste, useNotifications, useFilePicker, useMicrophone.
  • Wrapper SDKcreateGguiGadget for Leaflet, Mapbox, Stripe, Chart.js, Three.js, or anything else with a browser API.

See Glossary for the gadget / tool / blueprint vocabulary, and Marketplace Registry for distribution.

Terminal window
npm install @ggui-ai/gadgets
import { createGguiGadget } from "@ggui-ai/gadgets";
export const useLeafletMap = createGguiGadget<
// TOutput — what the hook resolves with on `status: 'completed'`.
{ containerRef: (el: HTMLDivElement | null) => void },
// TOptions — what the calling component passes in.
{ center: [number, number]; zoom: number }
>({
// Canonical hook name — the export name a contract references
// under clientCapabilities.gadgets[<package>][<exportName>]. The
// `use`-prefixed camelCase name marks this export as a hook.
hook: "useLeafletMap",
// REQUIRED teaching text. Synth + decision LLM both see this with
// a 300-char per-entry budget; description is preserved, usage
// truncates first when over budget.
description:
"Render an interactive Leaflet map with tile layer and pan/zoom controls. Returns a container ref to attach to a <div>.",
usage:
"Mount when the intent names a rendered map (location browsing, route preview, points-of-interest grid). Pass `center: [lat, lng]` + `zoom: 2..20`.",
// REQUIRED example. JsonValue — concrete shape the LLM uses to
// pattern-match on cold gen.
example: {
call: "const map = useLeafletMap({ center: [37.7749, -122.4194], zoom: 12 });",
returns: {
status: "completed",
value: { containerRef: "<DOM ref callback>" },
},
},
// Optional anti-patterns / library quirks. Surfaces in the
// boilerplate generator's prompt; the LLM uses them to avoid known
// misuse patterns.
gotchas:
"Leaflet requires the container <div> to have a non-zero height before the map mounts — apply `style={{ height: 400 }}` (or similar) directly. Default-marker icons require leaflet.css to be in the document; the styleUrl on this descriptor covers that.",
// REQUIRED. `version` is part of the blueprint cache key, so a
// wrapper bump automatically invalidates stale cached generations.
version: "0.0.1",
// `package` AND `version` are both REQUIRED — bare npm name, exact
// semver pin (no URLs, no ranges). `bundleUrl` is optional; when
// set it wins for boilerplate import emission.
//
// Resolution order (operator wins over author wins over spec
// default): explicit `bundleUrl` → operator `bundleHost` override
// → author `bundleHost` → spec default `registry.ggui.ai`. With
// `bundleHost` + `package` + `version` the server assembles
// `https://<bundleHost>/bundles/<scope>/<name>/<version>/bundle.js`
// — prefer this over hardcoded `bundleUrl`.
package: "@my-org/ggui-leaflet",
bundleHost: "registry.ggui.ai",
// Optional companion CSS bundle. The wrapper SHOULD self-inject
// any required CSS in its hook; the styleUrl is purely an origin
// declaration so the renderer's CSP allows the load. Same
// `bundleHost`-based resolution applies — explicit `styleUrl`
// overrides the host-derived `style.css` path.
// API-call origins (XHR / fetch / WebSocket / `<img src>`). The
// renderer's Content-Security-Policy unions these into BOTH
// connect-src AND img-src so map tiles loaded via <img> work.
connect: ["https://tile.openstreetmap.org"],
// Public env keys this wrapper consumes (see "Public env channel"
// below). Both wire + registry zod schemas enforce the same
// `GGUI_PUBLIC_APP_[A-Z0-9_]+` prefix as App.publicEnv. Leaflet
// doesn't need a token; Mapbox does.
// requires: ["GGUI_PUBLIC_APP_MAPBOX_TOKEN"],
// The React hook. Wrappers bundle their underlying library at
// build time (esbuild / tsup) and `import` it inside the hook.
// The function MUST return the standard `{value, status, start, …}`
// shape that satisfies `GadgetHook<TOutput, TOptions>`.
hookImpl: (props) => {
// … wrap Leaflet's lifecycle into ggui's stable hook contract …
return {
value: { containerRef: () => undefined },
status: "completed",
start: async () => undefined,
};
},
});

The factory synchronously zod-validates the spec at module load. Missing description / usage / example, or a missing package / version, throws WrapperConformanceError with the field path. Authors see the error at import time — not at runtime.

useLeafletMap is a callable hook; the immutable wrapper descriptor is attached as useLeafletMap.descriptor. Operators register the descriptor on App.gadgets.

For distribution, wrappers ship as a ggui.gadget.json manifest (see Marketplace Registry). The registry’s publish endpoint computes the bundle’s sha384 and stamps it on the registered entry’s bundleSri; ggui gadget install writes the descriptor (including bundleSri + resolved bundleUrl) into ggui.json#app.gadgets[]. Hand-authored entries omit bundleSri — the iframe runtime falls back to integrity-less dynamic import().

Non-stdlib registered descriptors also carry typesUrl (HTTPS URL to the wrapper’s .d.ts) and typesSri (SHA-384 SRI over those types), so the generator can type-check against the wrapper’s real surface. typesUrl is REQUIRED at registration time for any non-stdlib package; typesSri is stamped by the registry on publish (optional for hand-authored entries).

Add the descriptor (or its JSON shape) to ggui.json#app.gadgets:

A GadgetDescriptor is a PACKAGE — package + version + transport metadata declared once, plus an exports[] array carrying each export’s teaching text:

{
"app": {
"slug": "leaflet-demo",
"name": "Leaflet gadget demo",
"gadgets": [
{
"package": "@my-org/ggui-leaflet",
"version": "0.0.1",
"bundleHost": "registry.ggui.ai",
"connect": ["https://tile.openstreetmap.org"],
"exports": [
{
"hook": "useLeafletMap",
"description": "Render an interactive Leaflet map…",
"usage": "Mount when the intent names a rendered map…",
"example": {
"call": "const map = useLeafletMap({ center: [37.7749, -122.4194], zoom: 12 });",
"returns": {
"status": "completed",
"value": { "containerRef": "<DOM ref callback>" }
}
},
"gotchas": "Leaflet requires the container <div>…"
}
]
}
]
}
}

A package that ships more than one export (two hooks, or a hook plus a React component) adds further entries to the same exports array — the transport metadata is declared once and shared.

The CLI threads app.gadgets into the in-process AppMetadataStore so the same singleton powers ggui_list_gadgets, handshake-time prompt injection, render-time validation + enrichment, and CSP derivation.

Two distinct shapes — do not confuse them. One is the wire map a host agent authors on a contract; the other is the registry-side package descriptor an operator registers. Both are organized around a gadget package that ships one or more exports, where each export is either a hook (a use-prefixed camelCase name) or a component (a PascalCase name):

  • Contract gadget refsclientCapabilities.gadgets on a DataContract is a package-keyed two-level map carrying IDENTITY ONLY. The outer key is the npm package name; the inner key is the export name; the inner value is a GadgetExportUse{ description?, usage? }, usually just {} (optional intent-specific override prose). There is NO hook / component field on the wire — the export-name GRAMMAR is the discriminator: useGeolocation is a hook, LeafletMap is a component. There is NO version, NO permission, NO transport metadata, and NO binding key. The wire carries (package, exportName) and nothing else.
  • GadgetDescriptor — the full registry-side PACKAGE descriptor (what operators register on App.gadgets). Package-level identity (package + version) plus transport metadata (bundle URLs, connect, typesUrl / typesSri) declared once, plus an exports: GadgetExport[] array (≥1). Each GadgetExport is a field-presence-discriminated union — a hook export { hook, … } or a component export { component, … } — carrying that export’s teaching text + per-export permission. version, transport metadata, permission, and requires ALL resolve server-side from this descriptor at render time; the contract author never restates them. createGguiGadget authors a single-export package; multi-export packages add further exports entries.

Once a package is registered on App.gadgets, contracts reference its exports through the package-keyed clientCapabilities.gadgets map — outer key = package name, inner key = export name:

const contract = {
// … propsSpec / actionSpec / contextSpec …
clientCapabilities: {
gadgets: {
"@my-org/ggui-leaflet": {
// PascalCase key ⇒ a component export. Empty value is the
// common case; an optional `{ description?, usage? }` override
// sharpens the teaching text for this specific intent.
LeafletMap: {},
},
"@ggui-ai/gadgets": {
// `use`-prefixed camelCase key ⇒ a hook export.
useGeolocation: {},
},
},
},
};

The wire is identity-only: render resolves each (package, exportName) pair against the registered GadgetDescriptor and enriches the persisted ComponentGguiSession with the canonical version, transport metadata, teaching text, and permission. To walk the map in code, call listContractGadgets(contract) — it flattens the package-keyed map into a readonly GadgetUse[], each entry { package, name, description?, usage? }.

References to unregistered exports reject at render validation with gadget_not_registered and a did-you-mean hint when a close stdlib match exists (Levenshtein < 3 cutoff). Render also rejects with gadget_package_mismatch when a referenced export name belongs to a different registered package than the one keyed.

When ANY gadget in clientCapabilities.gadgets declares bundleUrl / styleUrl / connect[], the render’s spec-canonical _meta.ui.csp block carries the derived { connectDomains, resourceDomains } (per the MCP Apps spec). The MCP-Apps host applies them to the sandboxed iframe as a Content-Security-Policy — conceptually:

script-src 'self' 'unsafe-inline' <bundleUrl-origins>;
style-src 'self' 'unsafe-inline' <styleUrl-origins>;
connect-src 'self' <connect-origins>;
img-src 'self' data: <connect-origins>
  • 'unsafe-inline' on script-src is needed because the render shell embeds the bootstrap as an inline <script> tag.
  • img-src derives from connect[] so map tiles loaded via <img> work without redeclaring origins.

The _meta.ui.csp block is populated ONLY when gadgets declare external origins. Pre-gadget scenarios stay CSP-clean.

STDLIB hooks (no operator config required)

Section titled “STDLIB hooks (no operator config required)”

Seven first-party browser-capability hooks are always available — useGeolocation, useCamera, useClipboardWrite, useClipboardPaste, useNotifications, useFilePicker, useMicrophone. The stdlib package (STDLIB_GADGETS from @ggui-ai/protocol) is a structural floor, not a fallback: declaring your own App.gadgets layers on top, and stdlib hooks stay referenceable in any contract (a declared package only overrides stdlib on an exact package collision).

Public env channel — wrappers that need a token

Section titled “Public env channel — wrappers that need a token”

Wrappers like Mapbox can’t render without an operator-provided token. The public env channel is the typed seam for that: wrappers declare which public keys they consume, operators stamp values on the App, and a single accessor reads them at hook-mount.

The four moving parts:

  1. Wrapper declares what it needs on requires:
export const useMapbox = createGguiGadget({
hook: "useMapbox",
// …description / usage / example / package / bundleUrl…
requires: ["GGUI_PUBLIC_APP_MAPBOX_TOKEN"],
hookImpl: () => {
// Lazy read inside the hook body — see the rule below.
const token = getPublicEnv("GGUI_PUBLIC_APP_MAPBOX_TOKEN");
mapboxgl.accessToken = token;
// …mount Mapbox…
},
});
  1. Operator stamps the value on ggui.json#app.publicEnv:
{
"app": {
"slug": "mapbox-demo",
"publicEnv": {
"GGUI_PUBLIC_APP_MAPBOX_TOKEN": "pk.eyJ..."
},
"gadgets": [
/* …useMapbox descriptor… */
]
}
}

Keys MUST match ^GGUI_PUBLIC_APP_[A-Z0-9_]+$ — the prefix is a security boundary, not a convention. Public means visible to anyone with iframe-source access (the server inlines projected values verbatim in the bootstrap script tag). Sensitive credentials belong on the agent-side tools surface, NOT here. The GGUI_PUBLIC_USER_ namespace is reserved for a future per-user channel.

  1. Render gate enforces at commit time. assertPublicEnvSatisfied runs alongside the registry gate; if any declared wrapper’s requires aren’t satisfied by App.publicEnv, render rejects with gadget_public_env_missing — the error names the missing key + the wrapper that required it, so the operator fix is unambiguous.

  2. Wrapper reads via getPublicEnv from @ggui-ai/gadgets:

import { getPublicEnv } from "@ggui-ai/gadgets";
// Throws with the available-keys diagnostic if the key is missing
// AND opts.optional is not set. Use the throwing default for keys
// you declared on `requires` (render gate guarantees presence) and
// {optional: true} only when the wrapper has a sensible fallback.
const token = getPublicEnv("GGUI_PUBLIC_APP_MAPBOX_TOKEN");
const base = getPublicEnv("GGUI_PUBLIC_APP_API_BASE", { optional: true });

The server projects only what wrappers need. derivePublicEnvProjection computes the union of every declared wrapper’s requires for the render, then filters App.publicEnv to that subset before stamping _meta["ai.ggui/render"].publicEnv. Operator-stamped keys that no wrapper consumes stay server-side. Minimum-disclosure by construction.

Reference samples ship in the OSS repo under samples/gadgets/ and samples/gguis/ — start with the Leaflet pair (no token), then Mapbox (public-env channel end-to-end):

  • @ggui-samples/gadget-leaflet + @ggui-samples/ggui-leaflet-demo — Leaflet wrapper, no token required. The recommended “first wrapper” example.
  • @ggui-samples/gadget-mapbox + @ggui-samples/ggui-mapbox-demo — Mapbox wrapper with the public env channel wired end-to-end. The committed demo ggui.json uses a <set-me-before-running> placeholder for the token (the render gate accepts it; the Mapbox SDK rejects it on first tile request — replace before running).

Publish wrappers to the marketplace registry at registry.ggui.ai for public discovery, or self-host your own registry for private gadgets. See Marketplace Registry and Self-Hosted Registry.

  • Per-user public env (GGUI_PUBLIC_USER_*). Reserved namespace — the wire regex rejects it today. A follow-up wires per-user auth-overlay defaults + explicit per-render publicEnv input once the per-user shape is locked.