---
title: "Widget SDK"
description: "The GlassHome widget SDK API. defineWidget, Widget components, hooks, entity bindings, and theming."
canonical: https://glasshome.app/docs/widgets/widget-sdk
section: "SDK"
updated: 2026-08-10
---
# Widget SDK

`@glasshome/widget-sdk` (on npm) provides `defineWidget`, the [Config API](/docs/widgets/widget-sdk/config) (`defineConfig` + `field.*`), composable UI components, reactive hooks, the Home Assistant data layer, and theming utilities for SolidJS widgets.

The runtime constant `SDK_VERSION` equals the installed package version; the host compares it against each widget's `manifest.sdkVersion` range.

This page is the **guide**. For every public export across the three entry points, see the [API Reference](/docs/widgets/widget-api-reference).

## On this page

- [`defineWidget`](#definewidget) and [manifest fields](#manifest-fields)
- [`widgetFields`](#widgetfields) and the [complete widget example](#complete-widget-example)
- [`<Widget>`](#widget-component): [variants](#variants) and [slot subcomponents](#slot-subcomponents)
- The [`<WidgetDialog>`](#widgetdialog-component) settings dialog
- [Hooks](#hooks), [Home Assistant data & services](#home-assistant-data--services), the [`EntityView`](#the-entityview-shape) shape
- [Errors and empty states](#errors-and-empty-states), [theming](#theming), [entity utilities](#entity-utilities), [build tooling](#build-tooling)

## defineWidget

The entry point for every widget: a manifest (metadata), an optional Zod config schema, an optional `migrate` function, and a SolidJS component.

```ts
function defineWidget<C = Record<string, unknown>>(
  definition: WidgetDefinition<C>
): WidgetDefinition<C>
```

`WidgetDefinition<C>` shape:

```ts
interface WidgetDefinition<C = Record<string, unknown>> {
  manifest: WidgetManifest;
  configSchema?: ZodType<C, unknown>;
  migrate?: (config: Record<string, unknown>, fromConfigVersion: number) => Record<string, unknown>;
  component: (props: { config: C }) => any;
}
```

Providing `configSchema` auto-populates `manifest.schema` (JSON Schema) and `manifest.defaultConfig`. Types derive via `z.infer`; the dashboard renders the edit form from the schema.

## Manifest fields

The runtime loads the inline `manifest` in `defineWidget`; the CLI reads `src/<name>/manifest.json` for build and publish metadata (`bun widget upgrade` writes `sdkVersion` back to that file). Keep the two in sync: the build step warns on schema drift.

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | `string` | yes | Display name and identifier |
| `description` | `string` | no | Short description shown in the widget picker |
| `icon` | `string` | no | Iconify icon, e.g. `"mdi:lightbulb"` |
| `minSize` | `{w, h}` | yes | Minimum grid size |
| `maxSize` | `{w, h}` | yes | Maximum grid size |
| `defaultSize` | `{w, h}` | no | Initial size when added to a dashboard |
| `sdkVersion` | `string` | yes | Required SDK version range, e.g. `"^1.0.0"` |
| `capabilities` | `CapabilityGrant[]` | yes (1.x) | Home Assistant domains the widget reads or controls; approved by the user, enforced by the host. Empty array for widgets that touch no HA data. See [Capabilities & Permissions](/docs/widgets/widget-capabilities). |
| `configVersion` | `number` | no | Bump when config shape changes; used with `migrate` |
| `schema` | `object` | no | Auto-populated from `configSchema`. Do not set manually. |
| `defaultConfig` | `object` | no | Auto-populated from Zod `.default()` calls. |
| `cssUrl` | `string` | no | Set by the build when the widget emits a stylesheet. Do not set manually. |

## widgetFields

> **Deprecated in 1.4.0:** `widgetFields.*` and building `configSchema` with raw `z.object({ ... })` still
>   work, but they're deprecated and will be removed in a future major release. Use
>   the [Config API](/docs/widgets/widget-sdk/config) (`defineConfig` + `field.*`) instead, or
>   run `bun widget migrate config`. `bun widget build` warns on each use.

Pre-built Zod field helpers; each attaches metadata so the dashboard renders the right form control.

| Helper | Renders as | Replacement |
|---|---|---|
| `widgetFields.title()` | Text input labelled "Title", hint "Optional display name override" | `field.title()` |
| `widgetFields.entityIds(domain)` | Multi-select entity picker filtered by domain | `field.entities(domain)` |
| `widgetFields.singleEntity(domain)` | Single-select entity picker filtered by domain | `field.entity(domain)` |
| `widgetFields.areaId()` | Area picker dropdown | `field.area()` |

See the [Config API](/docs/widgets/widget-sdk/config) for the full `field.*` surface (`field.text`, `field.number`, `field.toggle`, `field.choice`, `field.stringList`, `field.group`) and type inference with `Infer`.

## Complete widget example

A working example mirroring the official `@glasshome` widgets: config schema, entities bound from config via `useEntities`, group aggregation via `useWidgetEntityGroup`, a tap-to-toggle gesture, and a schema-driven settings dialog. Note that **you bind entities yourself** from the ids the user picked in config; there is no hidden injection.

```tsx
// src/my-lights/index.tsx
import {
  defineWidget,
  Widget,
  WidgetDialog,
  useEntities,
  useService,
  useWidgetContext,
  useWidgetDialog,
  useWidgetEntityGroup,
  useWidgetGestures,
  widgetFields,
} from "@glasshome/widget-sdk";
// Dialog chrome (ResponsiveDialog, Button, SchemaForm) is injected, not bundled by the SDK.
// The official widgets re-export these from @glasshome/ui as a `widgetDialogProps` object.
import {
  Button,
  ResponsiveDialog,
  ResponsiveDialogContent,
  ResponsiveDialogDescription,
  ResponsiveDialogHeader,
  ResponsiveDialogTitle,
  SchemaForm,
} from "@glasshome/ui/solid";
import { onCleanup } from "solid-js";
import { z } from "zod";

const configSchema = z.object({
  title: widgetFields.title(),
  entityIds: widgetFields.entityIds("light"),
});

type Config = z.infer<typeof configSchema>;

function MyLightsWidget(props: { config: Config }) {
  const ctx = useWidgetContext();
  const { openDialog, setShowDialog, dialogProps } = useWidgetDialog();
  const { toggle } = useService();

  // Bind the entities the user picked in config. Reactive: updates when state changes.
  const entities = useEntities(() => props.config.entityIds);

  const { aggregatedData, emptyState, hasEntities } = useWidgetEntityGroup({
    entities,
    aggregationMode: () => "light",
    emptyStateConfig: {
      icon: <span>💡</span>,
      title: "No lights",
      message: "Hold to configure",
    },
  });

  const lightData = () => aggregatedData();
  const isOn = () => lightData()?.isOn ?? false;

  const gestures = useWidgetGestures(() => ({
    tap: () => toggle(entities().map((e) => e.id)),
    hold: { action: openDialog },
  }));
  onCleanup(gestures.dispose);

  return (
    <>
      <Widget
        gestures={gestures}
        variant="classic-glass"
        tone={isOn() ? "accent" : "neutral"}
        emptyState={emptyState()}
        loading={!hasEntities()}
      >
        <Widget.Title>{props.config.title || "Lights"}</Widget.Title>
        <Widget.Status>{isOn() ? "On" : "Off"}</Widget.Status>
        <Widget.Value>
          {lightData() ? `${lightData()!.onCount}/${lightData()!.totalCount}` : ""}
        </Widget.Value>
      </Widget>
      <WidgetDialog
        {...dialogProps}
        ResponsiveDialog={ResponsiveDialog}
        ResponsiveDialogContent={ResponsiveDialogContent}
        ResponsiveDialogHeader={ResponsiveDialogHeader}
        ResponsiveDialogTitle={ResponsiveDialogTitle}
        ResponsiveDialogDescription={ResponsiveDialogDescription}
        Button={Button}
        SchemaForm={SchemaForm}
        title="Lights"
        maxWidth="lg"
        configSchema={configSchema}
        config={props.config}
        onConfigSave={(config) => {
          ctx.updateConfig(config);
          setShowDialog(false);
        }}
      />
    </>
  );
}

export default defineWidget<Config>({
  manifest: {
    name: "My Lights",
    description: "Toggle a group of lights",
    icon: "mdi:lightbulb-group",
    minSize: { w: 1, h: 1 },
    maxSize: { w: 4, h: 4 },
    sdkVersion: "^1.0.0",
    capabilities: [{ domain: "light", access: "control" }],
  },
  configSchema,
  component: MyLightsWidget,
});
```

> **Binding entities from config:** `useEntities(() => props.config.entityIds)` resolves the ids the user picked (via the `widgetFields.entityIds` picker) into live `EntityView[]`. Pass that accessor straight into `useWidgetEntityGroup`. For a single-entity widget use `useEntity(() => props.config.entityId)` instead. The scaffold template ships this exact wiring.

> **The dialog needs UI components:** `<WidgetDialog>` renders no chrome of its own. It takes `ResponsiveDialog`, `Button`, `SchemaForm`, and friends as props so your widget controls the look. Import them from <code>@glasshome/ui/solid</code> (keep `@glasshome/ui` as a dependency). See [WidgetDialog component](#widgetdialog-component) for the full prop list.

## Widget component

`<Widget>` is the main container: context, gesture handling, theming, and base styling.

### Props

```ts
interface WidgetProps {
  variant?: string | WidgetVariantConfig;
  tone?: Tone;           // semantic color: resolves to --widget-color
  color?: string;        // CSS color override for --widget-color (overrides tone)
  colorTo?: string;      // second gradient stop (--widget-color-to)
  gradient?: string;     // full CSS gradient (overrides auto-shell)
  loading?: boolean;
  class?: string;
  isEditMode?: boolean;
  onDelete?: () => void;
  emptyState?: { icon?: JSX.Element; title?: string; message?: string };
  gestures?: GestureHandlers; // return value of useWidgetGestures(...)
  children?: JSX.Element;
}
```

Pass the return value of `useWidgetGestures(...)` to `gestures`, not a plain object.

### Variants

`variant` picks a pre-built look (shell background, blur, layout defaults, hover/active behavior): a built-in id string or a full `WidgetVariantConfig` object. The SDK ships three:

| `variant` id | Look |
|---|---|
| `"classic-glass"` | Default glassmorphism: blurred translucent background, gradient shell, hover/active scale. What every official `@glasshome` widget uses. |
| `"minimal"` | Clean, no background effects. Tighter padding. |
| `"compact-horizontal"` | Horizontal layout for short, wide tiles. |

```tsx
<Widget variant="classic-glass" tone="accent">…</Widget>
```

> **No variant means no shell styling:** If you omit `variant`, no variant styling is applied (you get a bare shell). For the standard GlassHome look, pass `variant="classic-glass"`. The scaffold and every built-in widget do.

`WidgetVariantConfig` is exported from the SDK for fully custom looks. Tone and color props ([Theming](#theming), [Styling](/docs/widgets/widget-styling#coloring-the-shell)) layer on top of whichever variant you choose.

### Slot subcomponents

| Slot | Key props | Purpose |
|---|---|---|
| `Widget.Icon` | `icon: JSX.Element`, `color?`, `dimmed?`, `entityCount?` | Entity icon with adaptive color |
| `Widget.Title` | `children` | Primary label |
| `Widget.Status` | `children` | State text (On, Off, etc.) |
| `Widget.Value` | `children` | Numeric or formatted value |
| `Widget.Content` | `children`, `class?` | Freeform content area |
| `Widget.SliderFill` | `value: number` (0–100), `color?`, `isDragging?` | Background fill for slider widgets |

`Widget.SliderFill` is also exported standalone as `WidgetSliderFill` (same component). Pass `isDragging` so the fill tracks the finger 1:1 while dragging (drops the transition). See [Animation](/docs/widgets/widget-styling#animation).

### Component props

Every widget component receives one prop, `{ config: C }`, where `C` is the type passed to `defineWidget<C>`. The host parses, migrates, and validates the saved config first, so `config` always matches your current schema type.

## Hooks

### `useWidgetContext()`

Returns `ReactiveWidgetContext`. Must be called inside the `<Widget>` render tree.

```ts
interface ReactiveWidgetContext {
  isEditMode: () => boolean;
  updateConfig: (config: Record<string, unknown>) => void;
  dimensions: () => { width: number; height: number }; // CSS px, (0,0) before first layout
  registerDialogOpener?: (open: ((tab?: string) => void) | null) => void;
}
```

### `useWidgetEntityGroup(options)`

Aggregates one or many entities with built-in aggregation presets and empty-state support.

```ts
function useWidgetEntityGroup<TData = unknown>(
  options: UseWidgetEntityGroupOptions<TData>
): UseWidgetEntityGroupResult<TData>
```

**Options:**

| Field | Type | Description |
|---|---|---|
| `entities` | `Accessor<EntityView[]>` | Reactive entities array (required) |
| `emptyStateConfig` | `WidgetEmptyStateConfig` | Shown when `hasEntities` is false (required) |
| `aggregationMode` | `Accessor<AggregationPreset \| undefined>` | `"light"`, `"sensor"`, `"switch"`, `"binary-sensor"`, `"none"` |
| `sensorGroupType` | `Accessor<SensorGroupType>` | For `"sensor"` preset: `"min"`, `"max"`, `"mean"`, `"median"`, `"sum"`, `"last"`, etc. |
| `calculateGroupData` | `(entities: EntityView[]) => TData` | Custom aggregation function (alternative to `aggregationMode`) |
| `allEntitiesMode` | `boolean` | All entities must be on for group to be "on" (light/switch presets) |
| `minEntities` | `number` | Minimum entities required; default `1` |

**Return value:**

| Field | Type | Description |
|---|---|---|
| `entities` | `Accessor<EntityView[]>` | All entities |
| `groupData` | `Accessor<TData \| null>` | Result of `calculateGroupData`, or `null` |
| `aggregatedData` | `Accessor<LightGroupResult \| SensorGroupResult \| undefined>` | Result of preset aggregation |
| `emptyState` | `Accessor<WidgetEmptyStateConfig \| undefined>` | Defined when entities are missing |
| `hasEntities` | `Accessor<boolean>` | Whether minimum entities are present |
| `count` | `Accessor<number>` | Entity count |

`LightGroupResult` includes: `isOn`, `isUnavailable`, `brightness` (0-255), `brightnessPercent` (0-100), `color`, `onCount`, `totalCount`, `description`.

### `useWidgetDialog(defaultTab?)`

Controls the widget settings dialog. `defaultTab` defaults to `"controls"`.

```ts
interface WidgetDialogReturn {
  showDialog: () => boolean;
  setShowDialog: (open: boolean) => void;
  openDialog: (tab?: string) => void;
  closeDialog: () => void;
  activeTab: () => string;
  setActiveTab: (tab: string) => void;
  dialogProps: {
    open: boolean;
    onOpenChange: (open: boolean) => void;
    activeTab: string;
    onActiveTabChange: (tab: string) => void;
  };
}
```

Spread `dialogProps` onto `<WidgetDialog>` to wire open/close and tab state automatically.

## WidgetDialog component

`<WidgetDialog>` is the settings/detail dialog: a tabbed panel that can host device controls, a schema-driven config editor, and a debug view. It renders **no chrome of its own**; you inject the dialog/button/form components as props.

### Injected UI components (required)

These have no defaults; the dialog will not render without them. Import from `@glasshome/ui/solid`:

| Prop | Component |
|---|---|
| `ResponsiveDialog` | Dialog/drawer wrapper |
| `ResponsiveDialogContent` | Body |
| `ResponsiveDialogHeader` | Header region |
| `ResponsiveDialogTitle` | Title |
| `ResponsiveDialogDescription` | Subtitle/description |
| `Button` | Buttons (save, delete, tab controls) |
| `SchemaForm` | Form renderer for schema-driven config editing. Required only when you use `configSchema`. |

> **Info:** The official `@glasshome` widgets bundle these into a single `widgetDialogProps` object and spread it (``). Do the same in your project to keep call sites short.

### Content props

| Prop | Type | Purpose |
|---|---|---|
| `title` | `string` | Dialog title (required) |
| `controlsContent` | `JSX.Element` | Device-controls tab (sliders, buttons for the bound entities) |
| `configSchema` | `ZodType` | Enables the schema-driven config editor tab. Pair with `config` + `onConfigSave`. |
| `config` | `Record<string, unknown>` | Current config to seed the editor |
| `onConfigSave` | `(config) => void` | Called with the new config; typically `ctx.updateConfig(config)` then close |
| `editContent` | `JSX.Element` | Custom config UI, used **instead of** the schema editor when `configSchema` is absent |
| `debugContent` | `JSX.Element` | Debug tab body |
| `debugData` | `string \| Record<string, unknown>` | Raw debug payload (rendered if no `debugContent`) |
| `tabs` | `WidgetDialogTab[]` | Fully custom tabs: `{ id, label, icon, content }[]` |
| `defaultTab` / `activeTab` + `onActiveTabChange` | `string` / controlled pair | Initial or controlled active tab |
| `headerActions` | `JSX.Element` | Extra buttons in the header |
| `onDelete` | `() => void` | Renders a delete action |
| `maxWidth` | `"sm" \| "md" \| "lg" \| "xl" \| "2xl" \| "3xl" \| "4xl"` | Dialog max width |

Config-only widgets pass just `configSchema` + `config` + `onConfigSave`; control widgets add `controlsContent`. See the [complete example](#complete-widget-example) for the wiring.

### `useWidgetGestures(config, orientation?)`

Tap, hold, and slide gestures on any pointer type.

```ts
function useWidgetGestures(
  config: () => GestureConfig,
  orientation?: () => "horizontal" | "vertical" | "square"
): GestureHandlers
```

Both arguments are SolidJS accessors; orientation is optional and defaults to horizontal.

```ts
interface GestureConfig {
  tap?: () => void;
  hold?: { action: () => void; delay?: number }; // delay in ms, default 300
  slide?: {
    value: number;
    onChange: (value: number) => void;
    min?: number; max?: number;
    orientation?: "auto" | "horizontal" | "vertical";
    activationDelay?: number;
  };
}
```

`GestureHandlers` (returned value) carries `onPointerDown`, `onPointerMove`, `onPointerUp`, `onPointerCancel`, `onPointerEnter`, `bindElement`, `touchAction`, and `dispose`. Pass the whole object to ``. Call `dispose()` in `onCleanup`.

### `useReducedMotion()`

Reactive `prefers-reduced-motion` accessor, `() => boolean`; `false` when `matchMedia` is unavailable (SSR-safe).

### `useIntersectionPause(elAccessor)`

Returns `() => boolean`: `true` while the element (passed as an accessor) is off-screen; `false` when `IntersectionObserver` is unavailable (SSR-safe).

## Home Assistant data & services

Import all Home Assistant hooks from `@glasshome/widget-sdk`, never from `@glasshome/sync-layer` directly (a direct import fails the build and would bundle a second, disconnected store). Read hooks are SolidJS accessors backed by the host's live store, so UI stays reactive with no WebSocket state to manage. Reads and service calls are checked against your declared [capabilities](/docs/widgets/widget-capabilities).

### Reading entities

| Hook | Returns | Purpose |
|---|---|---|
| `useEntity(id)` | `Accessor<EntityView \| undefined>` | One entity by id (`id` may be a string or accessor) |
| `useEntities(ids)` | `Accessor<EntityView[]>` | Many entities; pass an accessor returning the id array |
| `useArea(id)` | `Accessor<AreaView \| undefined>` | An area and its entities |
| `useEntityHistory(id)` | `Accessor<EntityHistoryData \| undefined>` | Recent state history for an entity |
| `useEntityStatistics(id, options)` | `Resource<StatisticValue[]>` | Long-term statistics; the resource exposes `.loading` and `.error` |
| `useForecast(id)` | `Accessor<WeatherForecastsData \| undefined>` | Weather forecast for a weather entity |
| `useCamera(id)` | `{ stream, refresh }` | Reactive camera stream data |
| `useStore(selector)` | `Accessor<T>` | Escape hatch for direct store access when no specific hook fits |

```tsx
import { useEntity, useEntities } from "@glasshome/widget-sdk";

function Thermostat(props: { config: Config }) {
  const climate = useEntity(() => props.config.entityId);
  const temp = () => climate()?.attributes.current_temperature;
  return <Widget.Value>{temp() ?? "—"}</Widget.Value>;
}
```

### The `EntityView` shape

Every read hook returns `EntityView` objects. The most-used fields:

| Field | Type | Notes |
|---|---|---|
| `id` | `string` | Entity id, e.g. `"light.kitchen"` |
| `domain` | `string` | `"light"`, `"sensor"`, … |
| `state` | `string` | Current state value (`"on"`, `"22.5"`, …) |
| `attributes` | `Record<string, any>` | HA attributes, **minus** the four surfaced below. Read those from the canonical fields, not from here. |
| `friendlyName` | `string` | Display name |
| `icon` | `string \| null` | Resolved icon (registry → attribute → domain default) |
| `deviceClass` | `string \| null` | Resolved device class |
| `unitOfMeasurement` | `string \| null` | Resolved unit |
| `areaId` / `deviceId` | `string \| null` | Placement / device |
| `lastChanged` / `lastUpdated` | `Date` | Timestamps |

`EntityView` is exported as a type from the SDK for your own annotations.

### Connection & locale

| Hook | Returns |
|---|---|
| `useConnection()` | `{ status, isConnected }` accessors |
| `useHassConfig()` | `Accessor<HassConfig \| null>` |
| `useUnitSystem()` | `Accessor<HassUnitSystem \| null>` |
| `useTemperatureUnit()` | `Accessor<string>` (e.g. `"°C"`) |
| `useLocale()` | `Accessor<string>` (BCP 47) |
| `useCurrency()` | `Accessor<string>` (ISO 4217) |

### Calling services

`useService()` returns a capability-routed service caller plus shortcuts that take an entity id (or array) and optional service data:

```tsx
import { useService } from "@glasshome/widget-sdk";

function LightToggle(props: { config: Config }) {
  const { toggle, turnOn, callService } = useService();

  return (
    <Widget gestures={useWidgetGestures(() => ({ tap: () => toggle(props.config.entityId) }))}>
      {/* turnOn("light.x", { brightness_pct: 80 }) */}
      {/* callService("light", "turn_on", { brightness_pct: 80 }, { entity_id: "light.x" }) */}
    </Widget>
  );
}
```

| Hook | Returns |
|---|---|
| `useService()` | `{ callService, turnOn, turnOff, toggle }` |
| `useTurnOn()` / `useTurnOff()` / `useToggle()` | The single shortcut function |

`callService(domain, service, data?, target?)` is the general form; it resolves to `Promise<void>`. The host validates the call against your granted capabilities before forwarding it.

## Errors and empty states

- **Config schema parse failure.** If the stored config fails Zod validation after migration, the dashboard falls back to `configSchema.parse({})` (all-defaults), then to `{}`. No error is shown to the user; handle missing/default config gracefully.
- **Missing entities.** Pass an `emptyStateConfig` to `useWidgetEntityGroup`. When `hasEntities()` is false, `emptyState()` returns the config object; pass it to `<Widget emptyState={emptyState()}>` to render the built-in empty state UI.
- **Component throw.** A render throw is caught by an `ErrorBoundary`: after one crash, a retryable error card with a Retry button; after three crashes (configurable in the Dash host), a permanent "disabled" card until the page is refreshed.

## Theming

Widgets inherit the dashboard theme automatically and render in an isolated shadow root with their own CSS bundle. The essentials:

- Use the `tone` prop on `<Widget>` for semantic color (`tone="accent"`), or `color`/`colorTo`/`gradient` for a custom shell.
- `isDark()` returns the current theme as a boolean for logic; `dark:` Tailwind variants work in markup.
- The SDK also exports `injectTokens`, `Tone`, and `ToneSchema`.

The full styling model (Tailwind, your own CSS, theme variables, container queries, animation): **[Styling & Animation](/docs/widgets/widget-styling)**.

## Entity utilities

| Function | Description |
|---|---|
| `isEntityActive(entity)` | `true` for active states |
| `getEntityAttribute(entity, key)` | Read a specific attribute |
| `countActiveEntities(entities)` | Count of currently active entities |
| `calculateLightGroup(entities, allEntitiesMode?)` | Aggregate brightness/color/state across a light group |
| `calculateSensorGroup(entities, groupType?, ignoreNonNumeric?)` | Aggregate numeric sensors |

## Build tooling

The scaffolded `vite.config.ts` wires this up automatically. Two entrypoints for custom setups:

### `@glasshome/widget-sdk/vite`

| Export | Purpose |
|---|---|
| `glasshomeWidget(options?)` | Single-widget plugin: dev preview + library build |
| `glasshomeWidgets(options?)` | Multi-widget project plugin: per-widget builds + `registry.json` |
| `buildWidgets(options?)` | Programmatic build of all widgets (used by `bun widget build`) |
| `discoverWidgets(srcDir)` | Scan `src/` for subdirs containing `index.tsx` + `manifest.json` |
| `generateRegistry(srcDir, outDir)` | Write a `registry.json` from discovered manifests |
| `isWidgetExternal(id)` | `true` for host-provided packages (not bundled) |

### `@glasshome/widget-sdk/schemas`

Zod schemas for validating manifests and publish payloads. Used by the CLI and Hub.

Re-exported from the canonical [`@glasshome/widget-contract`](/docs/widgets/widget-capabilities) package shared by the SDK, CLI, Hub, and Dash, so the same schema validates a manifest everywhere.

| Export | Purpose |
|---|---|
| `widgetManifestSchema` (alias `WidgetManifestSchema`) | Validate a complete widget manifest |
| `publishManifestSchema` | Validate the manifest subset sent at publish time |
| `capabilitiesSchema` | Validate a `capabilities` array |
| `capabilityGrantSchema` | Validate a single `{ domain, access }` grant |
| `GridSizeSchema` | Validate `{ w, h }` |
| `parseGridSize` / `serializeGridSize` | Parse from / serialize to the stored JSON form |
| `formatSchemaError` | Turn a Zod error into a readable message |
| `PublishRequestSchema` / `PublishConfirmSchema` / `PublishBodySchema` | Publish-flow request bodies |
| `CapabilityGrant` (type) | TypeScript type for one grant |

`formatSchemaError` and `WidgetManifestSchema` are also available from the SDK main entry (`@glasshome/widget-sdk`) for convenience.