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.
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? }) |
Select | the literal union 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.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.
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" }),
});
// Config = { mode: "auto" | "heat" | "cool"; target: number; sensors: string[]; icon: string }
Icons are names, not imports
field.icon() stores an Iconify name like mdi:lightbulb. Render it with
<Icon icon={props.config.icon} /> from @iconify-icon/solid: 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.
One level deep
The settings form is a flat list of fields. field.group is the single
nested exception; don’t nest beyond that.
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:
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:
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:
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:
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> |