---
title: "Widget Config API"
description: "Declare a widget's settings form with defineConfig and field.* — field kinds, type inference, and raw schemas."
canonical: https://glasshome.app/docs/widgets/widget-sdk/config
section: "SDK"
updated: 2026-08-25
---
# Widget Config API

Declare a widget's settings form with `defineConfig` and the `field.*` builders. Pass the result to `defineWidget` as `configSchema`; the dashboard renders the edit form automatically.

```tsx
import { defineConfig, field, type Infer } from "@glasshome/widget-sdk";

const configSchema = defineConfig({
  title: field.title(),
  entityIds: field.entities("light"),
  showBrightness: field.toggle({ title: "Show brightness", default: true }),
});

type Config = Infer<typeof configSchema>;
```

`Infer<typeof configSchema>` is your config type — use it for `props.config`.

## Field kinds

| Field                                                                      | Renders                                                               | Config type                           |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------- |
| `field.title()`                                                            | Text input ("Title", optional)                                        | `string \| undefined`                 |
| `field.text({ title, description?, default? })`                            | Text input                                                            | `string` (optional without a default) |
| `field.number({ title, description?, min?, max?, default? })`              | Number input                                                          | `number`                              |
| `field.toggle({ title, description?, default? })`                          | Switch                                                                | `boolean`                             |
| `field.choice(values, { title, description?, default?, labels?, icons? })` | Select, or icon cards when `icons` is set                             | the literal union of `values`         |
| `field.choices(values, { title, description?, default?, labels? })`        | Multi-select toggle row                                               | an array of `values`                  |
| `field.entities(domain, { title?, description?, deviceClass? })`           | Multi entity picker                                                   | `string[]`                            |
| `field.entity(domain, { title?, description?, deviceClass? })`             | Single entity picker                                                  | `string[]`                            |
| `field.area({ title? })`                                                   | Area picker                                                           | `string \| undefined`                 |
| `field.icon({ title?, default? })`                                         | Icon picker                                                           | `string` (optional without a default) |
| `field.image({ title?, description?, default?, presets? })`                | Image picker, with your built-in pictures first when `presets` is set | `string` (optional without a default) |
| `field.stringList({ title, description? })`                                | String list                                                           | `string[]`                            |
| `field.group(shape, { title })`                                            | Nested group                                                          | the nested object                     |

A `default` makes the property required (it always fills in); omit it to make the property optional. `field.choice` keeps the exact string-literal union, so `props.config` stays fully typed.

`labels` names each option for the homeowner (`{ floor: "Floor lamp" }`); without one the raw value shows. `icons` gives each option an Iconify name (SDK 1.17 and up), and the form draws the choice as a grid of icon cards instead of a dropdown. An older dashboard ignores `icons` and keeps the dropdown.

```tsx
const configSchema = defineConfig({
  mode: field.choice(["auto", "heat", "cool"], { title: "Mode", default: "auto" }),
  target: field.number({ title: "Target °C", min: 5, max: 35, default: 21 }),
  sensors: field.entities("sensor", { deviceClass: "temperature" }),
  icon: field.icon({ title: "Icon", default: "mdi:thermostat" }),
  photo: field.image({ title: "Photo" }),
});
// Config = { mode: "auto" | "heat" | "cool"; target: number; sensors: string[]; icon: string; photo?: string }
```

> **Icons are names, not imports:** `field.icon()` stores an Iconify name like `mdi:lightbulb`. Render it with
> `<Icon icon={props.config.icon} />` from `@glasshome/widget-sdk` (SDK 1.15
> and up): the host provides it, so a widget never bundles an icon set.
> GlassHome serves icon data from its own origin and caches it, which is what
> keeps a widget's CSP free of third-party hosts. The older
> `@iconify-icon/solid` import still renders but is deprecated and goes away
> in SDK 2.0; `bun widget build` points at each remaining use.

> **Images are ids, not URLs:** `field.image()` stores an opaque id for a picture the homeowner uploaded to
> their dashboard, never a URL. Turn it into something an `<img>` can load with
> `imageUrl(id)` from `@glasshome/widget-sdk`:
>
> ```tsx
> import { imageUrl } from "@glasshome/widget-sdk";
>
> const src = () => imageUrl(props.config.photo);
> ```
>
> The resolver's general name is `mediaUrl`; `imageUrl` is the image-flavoured
> alias, and the two are the same function. Either one reads fine here.
>
> `undefined` is a normal answer, not an error: the homeowner may not have picked
> anything yet, and a preview or render host may have no media store at all. Draw
> your own empty state or your own bundled art when it comes back undefined, and
> handle `onError` on the `<img>` for the picture that was deleted after it was
> picked.
>
> There is no plural field. A carousel is `field.list` of an object wrapper
> holding a `field.image` and a `field.text` caption.

> **Built-in pictures:** A widget that ships its own pictures offers them in the same picker through
> `presets` (SDK 1.17 and up). Each preset has a `label`, a small `thumb` for
> the picker and the full `src`; only the label and thumb travel in the
> manifest. Resolve the stored value with `imageSrc`, which returns the preset's
> picture or the uploaded image:
>
> ```tsx
> import { field, imageSrc } from "@glasshome/widget-sdk";
> import coast from "./coast.webp";
> import coastThumb from "./coast-thumb.webp";
>
> const PHOTOS = { coast: { label: "Coast", src: coast, thumb: coastThumb } };
> const configSchema = defineConfig({ photo: field.image({ title: "Photo", presets: PHOTOS }) });
>
> const src = () => imageSrc(props.config.photo, PHOTOS);
> ```
>
> Keep thumbs small (about 128px, a few KB): the manifest carries them. An
> example can pick a preset with `presetValue("coast")`, which is what the
> picker stores.

> **Keep the form shallow:** The settings form is a flat list of fields. `field.group`, `field.list` and
> `field.variants` below are the structured exceptions; a list never holds
> another list.

## Repeating and branching fields

Two field kinds build shapes the flat list can't (SDK 1.9.0+).

`field.list(item, opts)` renders an add / remove / reorder list of sub-forms:

```tsx
const configSchema = defineConfig({
  nodes: field.list(
    {
      label: field.text({ title: "Label" }),
      entity: field.entity("sensor"),
    },
    { title: "Nodes", max: 8, labelField: "label" },
  ),
});
// Config = { nodes: { label: string; entity: string }[] }
```

`max` is required and caps at 24. Every item is a rendered subtree and usually its own entity subscription, so an uncapped list is a performance cliff you'd hand to the user. `labelField` names the field shown on each collapsed row, and it has to exist in the item shape. A list inside a list throws when the schema is defined, including one hidden inside a `variants` branch.

`field.variants(discriminator, variants, opts)` builds a tagged union, for when a config means different things depending on a kind:

```tsx
const configSchema = defineConfig({
  source: field.variants(
    "kind",
    {
      entity: { entity: field.entity("sensor") },
      fixed: { value: field.number({ title: "Value", default: 0 }) },
    },
    {
      title: "Source",
      labels: { entity: "From an entity", fixed: "A fixed value" },
      shared: { unit: field.text({ title: "Unit" }) },
    },
  ),
});
```

`shared` fields are merged into every branch, and `labels` name the branches in the form's kind selector.

> **Adopting either is a config shape change:** Moving existing settings into a `list` or `variants` changes the stored shape,
> so bump `configVersion` in `defineWidget` and write the migration. Old configs
> still parse, which is exactly why the build guard, not the type checker, is
> what catches a missing bump.

## Advanced: raw schemas

For validation `field.*` can't express (custom checks, unions), import `z` and use it directly — `defineConfig` accepts raw schemas alongside fields:

```tsx
import { defineConfig, field, z } from "@glasshome/widget-sdk";

const configSchema = defineConfig({
  title: field.title(),
  webhook: z.string().url().meta({ title: "Webhook URL" }),
});
```

## Migrating to 1.4.0

1.4.0 replaced the old config helpers (`widgetFields.*` and raw `z.object`) with `defineConfig` + `field.*`. Existing widgets keep working; migrate when convenient. New widgets can skip this section.

> **widgetFields is deprecated:** `widgetFields.*` and building `configSchema` with raw `z.object({ ... })` still
> work, but they're deprecated and will be removed in a future major release.
> `bun widget build` warns on each use, so you can migrate when convenient.
>
> The same applies to naming a field `areaId` to get an area picker. The host
> used to infer one from the property name, so any other name silently rendered
> a text input. Declare `field.area()` instead — it works under any name.

Run the codemod from your widget project:

```bash
bun widget migrate config          # migrate every widget
bun widget migrate config --dry    # preview without writing
bun widget migrate config --name clock
```

Anything it can't safely rewrite is left in place and reported, so nothing breaks silently.

| Before                                     | After                           |
| ------------------------------------------ | ------------------------------- |
| `widgetFields.title()`                     | `field.title()`                 |
| `widgetFields.entityIds(domain, opts?)`    | `field.entities(domain, opts?)` |
| `widgetFields.singleEntity(domain, opts?)` | `field.entity(domain, opts?)`   |
| `widgetFields.areaId()`                    | `field.area()`                  |
| `z.object({ ... })`                        | `defineConfig({ ... })`         |
| `z.infer<typeof configSchema>`             | `Infer<typeof configSchema>`    |