Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Config schema and UI schema

Two JSON artifacts drive the settings form a customer sees in the dashboard:

  • configSchema — a JSON Schema object describing every setting your widget accepts.
  • uiSchema — an optional symbolic map naming which form control renders each field.

You never hand-write either. You write one zod schema that extends the shared base widgetSchema, and export zodToManifestSchema(configZod). Everything on this page is a consequence of that round-trip.

Enforcement levels used below (publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated)) are defined once in limits-and-errors.md. Every rule on this page applies to both manifest shapes unless a row or heading says Single-widget or Suite.

Where the two artifacts live

FieldTypeRequiredRuleOn violation
configSchema (manifest root)JSON Schema objectSingle-widget: expectedThe dashboard reads it directly off the cached root manifest.publish warning: manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be empty.
uiSchema (manifest root)objectSingle-widget: optionalSymbolic UI for your own fields only.advisory (unvalidated)
widgets[].manifestconfined relative pathSuite: expectedPoints at widgets/{slug}.manifest.json, which carries that widget’s configSchema + uiSchema. Field rule lives in widget-summary.md.publish ERROR if declared and absent from the built zip
configSchema / uiSchema on a suite ROOTSuite: never readNothing reads them once widgets[] is non-empty. Ship them per widget.silently ignored

In a suite, every widget owns its own src/widgets/{slug}/config.ts. See manifest-shapes.md for how the platform tells the shapes apart.

The pipeline

# traced through packages/homerunner-widget-core/src/plugin-schema.ts,
# packages/create-hr-plugin/template/scripts/inject-schema.mjs,
# src/lib/homerunner/plugin-widgets.ts and src/lib/plugin-ui-adapter.tsx

src/config.ts
  widgetSchema.extend({ ...your fields })            zod, in your project
        │
        ├─ zodToManifestSchema(configZod)            widget-core /plugin-schema
        │      → JSON Schema (refs inlined)
        │
        └─ uiSchema: PluginUiSchema                  string component names
        │
  scripts/inject-schema.mjs (yours, not the SDK's)
        → manifest.configSchema / manifest.uiSchema  (single-widget)
        → widgets/{slug}.manifest.json               (suite)
        │
  dashboard: @n8n/json-schema-to-zod                 JSON Schema → zod, in the browser
        + resolvePluginUiSchema()                    names → React components
        → the same <Form> system widgets use
        │
  autosaved to central as opaque JSON
        │
  props.options at runtime — RAW, unparsed, on BOTH server and client
        → parseWidgetConfig(configZod, props.options)

The round trip through JSON Schema is lossy. zodToManifestSchema runs zodToJsonSchema(schema, { $refStrategy: "none" }) and then strips $schema, $defs, definitions, $ref and the boolean additionalProperties: false marker, while preserving schema-valued additionalProperties so z.record(...) survives the trip back to zod. It recurses only into properties, items and additionalPropertiesanyOf / allOf / oneOf subtrees pass through untouched. What survives is tabulated below.

The base schema: widgetSchema

Every widget, first-party or plugin, extends the same base. It has exactly nine top-level fields, in this order. All are defaulted, so the emitted JSON Schema carries no root required array.

FieldShapeDefaultWho renders it
spacing{margin, padding, width, maxWidth}, each side {value, unit}margin 0 auto (top/bottom 0px, left/right auto), padding all 0px, width {0,"auto"}, maxWidth {100,"%"}Dashboard: one compound Spacing control. You: apply resolveWidgetSpacingStyle(options) to your outer element.
colorScheme"light" | "dark" | "auto" | "global""global"Dashboard: labelled select (Global (inherit from feed)). Resolved before you see it.
filter{feed_id?: int, platform?: string|null, property?: string}.optional()noneDashboard: Filter Settings panel; feed_id is rendered but hidden.
lightModeColors{accent: string}#f59e0cDashboard: colour picker, hidden when colorScheme === "dark". You: apply the colour.
darkModeColors{accent: string}#f59e0cDashboard: colour picker, hidden when colorScheme === "light". You: apply the colour.
font{size: number, family: string}{size: 16, family: "Inter"}Dashboard: size input + Google-font picker. You: apply both.
language{useAutoDetectedLocale: boolean, locale: string}{true, "en_US"}Dashboard: toggle + locale picker (picker hidden while auto-detect is on).
section{show: boolean, title: string|null}{show: false, title: ""}Dashboard: Section Header panel. You: render the heading.
customCssstring | null""Dashboard: CSS editor. You: render it.

There is no width field. spacing replaced the old width: {maxWidth, unit} pair (widget-core 0.9.0+ — see ../get-started/01-install-and-versions.md for which versions you can actually install). Old saved values are lifted onto spacing automatically by parseWidgetConfig. A plugin whose own schema still declares width keeps a compatibility Width Settings panel; a plugin that does not declare width never renders that entry.

Not supported yet. The platform applies none of customCss, font.size, font.family or the accent colours for you — not on the server and not in mount(). They arrive in options and your component must render them. See ../recipes/styling-and-theming.md.

Skipping the base is a publish warning, checked on the built zip:

# src/lib/plugin-zip.ts — warnIfSchemaSkipsBase()
{label}'s configSchema doesn't extend the base widgetSchema (no colorScheme/spacing) —
standard theme + spacing controls won't appear. Build config.ts as widgetSchema.extend({...})
and export zodToManifestSchema(configSchema).

{label} is the plugin for a single-widget manifest and widget "{slug}" for each suite widget. The check is literally “does properties contain both colorScheme and spacing”.

Writing config.ts

// packages/create-hr-plugin/template/src/config.ts — the scaffold, verbatim
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import {
  zodToManifestSchema,
  type PluginUiSchema,
} from "@homerunner-next/widget-core/plugin-schema";

export const configZod = widgetSchema.extend({
  title: z.string().default("My Widget").describe("Widget title"),
});

/** Symbolic UI for plugin-specific fields only. */
export const uiSchema: PluginUiSchema = {
  title: { component: "TextInput" },
};

/** JSON Schema shipped in `manifest.configSchema`. */
export const configSchema = zodToManifestSchema(configZod);

export type PluginConfig = z.infer<typeof configZod>;

Rules that are not enforced anywhere but that you will regret ignoring — all advisory (unvalidated):

  • .default(...) every field. A field with no default has no seed value, and the form live-validates on mount, so the customer opens the panel to a red error they did not cause.
  • .describe(...) every field. The string becomes JSON Schema description and renders as the field’s help text. On an object it also becomes the auto-generated panel’s description.
  • Use the narrowest zod type. z.number().int().min(1).max(20) emits {"type":"integer","minimum":1,"maximum":20}, which the number input honours.
  • Group with z.object({...}).default({...}). Object fields become panels automatically.

Reading options back: parseWidgetConfig

props.options is the raw merged settings blob on both the server and the client. Nothing schema-parses it for you — plugins are handed the unparsed object in the component, in getInitialData, in dehydrateState and in getStaticAssets. Parsing is your job, in every one of those places:

// packages/create-hr-plugin/template/src/widget.tsx — the scaffold, verbatim
const options = React.useMemo(
  () => parseWidgetConfig(configZod, props.options) as PluginConfig,
  [props.options],
);

parseWidgetConfig(schema, options, pre = liftLegacySpacing) does four things in order:

  1. stripNullSettingLeaves — drops a null or "" leaf only when that leaf’s own schema rejects it. Central runs Laravel’s ConvertEmptyStringsToNull, so any untouched optional field can come back null; older blobs carry "" on enum and number fields. A leaf that legitimately accepts null (.nullable() / .nullish()) keeps it, and "" stays on a plain z.string(). An empty array on a record- or object-typed field reads as {} (PHP serialises a cleared map as []). Anything else invalid — "abc" on a number — is left in place to fail loudly.
  2. coerceScalarSettingLeaves — a string leaf whose schema is z.number() or z.boolean() is coerced when it parses unambiguously (font.size: "16"16).
  3. pre — by default liftLegacySpacing, which folds a legacy width: {maxWidth, unit} (and top-level margin / padding) onto spacing without overwriting an existing value.
  4. schema.parse(...) — applies your defaults and throws on anything still invalid.

A throw here blanks the widget: on the client it is caught by the render boundary and the host gets data-hr-error="render-failed"; on the server a content widget is dropped from the page while a layout shell takes the whole page down. See component-and-ssr-module.md for the failure semantics and ../concepts/settings-and-options.md for where the raw blob comes from.

Not supported yet. There is no server-side validation of stored settings. The dashboard validates live for display only and autosaves the raw form value on every keystroke, even while invalid; central stores settings as opaque JSON and replaces it wholesale. Your parseWidgetConfig call is the only real gate.

What survives the manifest round-trip

Verified by running zodToManifestSchema@n8n/json-schema-to-zod (the exact pair the dashboard uses) over each construct. Enforcement for this whole section is advisory (unvalidated) — no audit inspects which zod constructs you used. Everything below fails in the customer’s browser, after publish, or not at all.

Safe

zod constructEmitted JSON SchemaComes back as
z.string(){"type":"string"}ZodString
z.number(), z.number().int().min(1).max(10){"type":"integer","minimum":1,"maximum":10}ZodNumber
z.coerce.number(){"type":"number"}ZodNumber (the coercion is dropped, which is fine — see step 2 of parseWidgetConfig)
z.boolean(){"type":"boolean"}ZodBoolean
z.enum([...]){"type":"string","enum":[...]}ZodEnum
z.object({...}), nested, .default({...}){"type":"object","properties":{...},"default":{...}}ZodDefault<ZodObject>
z.array(z.string()), z.array(z.object({...})){"type":"array","items":{...}}ZodArray
z.array(z.enum([...])){"type":"array","items":{"type":"string","enum":[...]}}ZodArray<ZodEnum> → multi-select
z.record(z.string(), z.string()){"type":"object","additionalProperties":{"type":"string"}}ZodRecord
.optional() / .nullable() / .nullish(){"type":["string","null"]} for nullishrenders correctly
.default(v) at any depthdefaultZodDefault
.describe(s)descriptionfield help text
.url() / .email()format: "uri" / "email"ZodString (no client-side format check)
.regex(re)patternZodString, and the pattern still rejects on validate
z.string().min(3).max(9)minLength / maxLengthZodString, still enforced

Degrades silently

Nothing warns. The form renders, the customer edits, and the result is wrong.

zod constructWhat actually happens
z.union([A, B])Emits anyOf. Both the default-seeder and the renderer take the first non-null branch, so only branch A is ever editable — while the schema still validates every branch, so a value shaped like branch B saves fine and then shows the wrong controls.
.refine() / .superRefine()Erased by zodToJsonSchema. Cross-field rules are not enforced in the dashboard at all — only in your own parseWidgetConfig call at runtime, where a failure blanks the widget instead of showing a form error.
z.record(z.enum([...]), z.string())Emits propertyNames, which the browser-side converter drops. The key constraint is lost; values are still typed.
z.record(z.string(), z.number())Round-trips as a record, but the key/value editor writes strings, so every value the customer types fails your runtime parse. Keep record values z.string().
z.date()Emits {"type":"string","format":"date-time"} and returns as ZodString — a plain text input, and your runtime parse then rejects a string where it expects a Date.
.catchall(...) / .passthrough()The open-key behaviour is lost on the way back; extra keys are dropped by the reconstructed closed object.

Breaks the whole panel

These make the dashboard’s default-value walker throw Unsupported schema type: [object Object]. That call is unguarded in three places in the plugin playground’s render, so the entire config page crashes — you do not get a broken field, you get no page.

zod constructRound-trips toWhy it throws
z.any(){}ZodOptional<ZodAny>ZodAny is not in the walker’s type list.
z.unknown(){}ZodOptional<ZodAny>same
z.literal("x"){"type":"string","const":"x"}ZodLiteralsame
z.tuple([...]){"type":"array","minItems":n,"maxItems":n,"items":{...}}ZodArray<ZodAny>the element type walks into ZodAny
z.intersection(A, B)allOfZodIntersectionsame
z.discriminatedUnion("t", [...])anyOf, first branch carrying a ZodLiteral discriminatorthe discriminator is a literal, so the first branch throws

Not supported yet. There is no guard around the walker and no error message — the panel simply does not render. Never ship any of the six constructs above. Model a variant with a z.enum() discriminator plus flat optional fields and a showIf per group.

The uiSchema

A manifest is JSON, so it cannot carry React components. You name them:

// property-picks/src/config.ts — a published plugin
export const uiSchema: PluginUiSchema = {
  title: { component: "TextInput", label: "Title" },
  limit: { component: "NumberInput" },
  showReviewQuote: { component: "Checkbox" },
  translations: {
    ui: { layout: "panel", label: "Translations" },
    viewDetails: { component: "Translations", label: "View details" },
    perNight: { component: "Translations", label: "/ night" },
  },
};

The types, from @homerunner-next/widget-core/plugin-schema:

// packages/homerunner-widget-core/src/plugin-schema.ts
export type PluginUiField =
  | {
      label?: string;
      description?: string;
      component?: /* one of the twelve tokens below */ string;
      optionLabels?: Record<string, string>;
      showIf?: { field: string; equals: unknown };
    }
  | PluginUiSchema;

export interface PluginUiSchema {
  [key: string]: PluginUiField | PluginUiLayoutSpec | undefined;
  ui?: PluginUiLayoutSpec;
}

export interface PluginUiLayoutSpec {
  layout?: "panel" | "collapsible";
  label?: string;
  description?: string;
  showIf?: { field: string; equals: unknown };
}

(component is a closed union of the twelve string literals in the real declaration; it is widened here only so the comment fits.)

PluginUiLayoutSpec has no fields key. A panel’s children are siblings of its ui key, mirroring the zod object’s shape. (A legacy {layout, label, fields: {...}} node is auto-normalised to the canonical form before the tree walk, so old manifests keep working. Do not write it in new code.)

Field keys the adapter honours

Exactly five. Everything else on a field entry is dropped or turned into an inert nested node.

KeyTypeEffect
labelstringForm label.
descriptionstringHelp text under the field; when omitted the zod .describe() text is used.
componentone of the 12 tokens belowSubstitutes the leaf renderer. An unrecognised name is silently ignored and the zod-type default renders.
optionLabelsRecord<string,string>Value → display label. Meaningful on Select and multi-selects.
showIf{field, equals}Conditional visibility, see below.

Not supported yet. order, ui:group, placeholder and every other key are ignored. pdp-suite’s shipped {"ui:group": "Content"} entries are no-ops — they produce an empty nested node and change nothing. Plugins cannot influence field order; see Ordering.

Component tokens

Twelve names resolve. Any other string is ignored.

TokenRendersPair with
TextInputsingle-line text inputz.string()
Textareamulti-line text areaz.string()
NumberInputnumeric input honouring minimum/maximumz.number()
Checkboxtogglez.boolean()
Selectdropdown built from the enum’s valuesz.enum([...]) only
ColorPickerhex colour pickerz.string()
FontSelectGoogle-font family pickerz.string()
LanguageSelectlocale pickerz.string()
PlatformSelectthe feed’s connected platforms, by UUIDz.string().nullish()
PropertySelectthe feed’s properties, by slugz.string()
WidgetSelect (widget-core 0.12.0+)the same widget combobox layout slots use; the stored value is a widget id, and a None row clears itz.string().optional()
Translationslocale-keyed string editorz.record(z.string(), z.string())

PlatformSelect, PropertySelect and WidgetSelect fetch the feed’s own data. WidgetSelect takes its feed from the form’s filter.feed_id, which the base filter block supplies and widget creation seeds — if you strip filter out of your schema, the combobox has no feed and stays empty.

Component ↔ zod pairing

The form dispatches on the zod type first and only then substitutes uiSchema.component into that type’s renderer slot. A token therefore receives the props of the branch the zod type routed to, not the props it was designed for.

MismatchResult
component: "Select" on a non-enum (z.string(), z.number())The enum renderer is handed options === undefined and throws on options.map. The panel crashes. Only the enum branch supplies options.
component: "Translations" on a non-recordThe locale editor is handed a non-object value, renders nonsense rows, and saves a shape your schema will reject.
Any token on z.object({...})Never renders — the object branch ignores Component entirely. The only effect is that the automatic panel is skipped, so the object’s children render flat instead of behind a row.
Any token on z.array(z.enum([...]))Substituted into the multi-select slot and handed options plus an array value.
Unrecognised tokenIgnored; the zod-type default renders.

Nothing validates any of this at publish time — advisory (unvalidated). Open the widget’s config panel in the dashboard before you submit.

The control you get with no uiSchema entry at all

zod typeControl
z.string()text input
z.number()number input
z.boolean()toggle
z.enum([...])dropdown
z.array(z.enum([...]))multi-select
z.array(<anything else>)repeater rows with add / remove
z.record(...)key/value rows with an explicit Save button
z.object({...})a nested panel (auto-generated)

Item-level UI for an array is declared under an element key on the array’s entry — the adapter forwards nested keys verbatim, and the array renderer reads element:

// derived from src/lib/plugin-ui-adapter.tsx + src/components/zod-form/libs/core/form.tsx
facts: { element: { component: "TextInput" } },

That is a structural passthrough rather than a declared feature — treat it as advisory (unvalidated).

Panels

Any object-typed property is wrapped in a nested panel automatically, recursively, whether or not you declare one. A panel renders as a clickable summary row; clicking it slides a stacked detail view in over the config sidebar. It is not an inline bordered fieldset and it is not a collapsible chevron.

  • Panel title = your ui.label, else the JSON Schema title, else the raw key. zod never emits title, so an undeclared panel is titled translations, display, slots.
  • Panel description = your ui.description, else the object’s .describe() text.
  • A field with an explicit component is never auto-wrapped, which is how Translations on a record (also type: "object") keeps its own editor.
  • The ui block is only read as a layout spec when it contains the layout key. A ui: { label: "Translations" } with no layout is discarded entirely — you lose the label and any showIf on it. Always write ui: { layout: "panel", label: "..." }.

Not supported yet. layout: "panel" and layout: "collapsible" are interchangeable. The value is only used as a marker that the node is a layout spec; it is never read, and both build the identical slide-in panel.

showIf

showIf: {field, equals} compiles to data => data?.[field] === equals, and that predicate is always invoked with the whole root form value.

RuleBehaviour
field must be a top-level key of your config. A dotted path like language.useAutoDetectedLocale is a plain property lookup, always undefined, so the field never shows.fails silently
Comparison is strict ===. It cannot match an object or an array. Compare against a string, number or boolean.fails silently
The same shape works on a panel’s ui block to hide the whole group.works
Hidden values are preserved: stripped from a draft before validation (so an invisible field cannot block the form with an unfixable error), grafted back after a successful parse, and never stripped from the autosave at all.works
// pattern verified against src/lib/plugin-ui-adapter.tsx + resolve-ui-schema-conds.ts
export const uiSchema: PluginUiSchema = {
  variant: { component: "Select", optionLabels: { grid: "Grid view", carousel: "Carousel" } },
  columns: { component: "NumberInput", showIf: { field: "variant", equals: "grid" } },
  advanced: {
    ui: { layout: "panel", label: "Advanced", showIf: { field: "mode", equals: "expert" } },
    retries: { component: "NumberInput", label: "Retries" },
  },
};

Ordering

Field order is the configSchema property order, stable-sorted by a numeric order that only the dashboard’s own base entries carry. widgetSchema.extend({...}) appends, so your fields always land after the base chrome:

# order values from src/app/(protected)/feeds/[id]/widgets/config/common-ui-schema.tsx
Section Header  (-55)
Filter Settings (-30)
spacing, colorScheme, lightModeColors, darkModeColors, font, language   (0, schema order)
…your fields…                                                          (0, schema order)
Custom CSS      (100)

Not supported yet. Plugins cannot reorder anything. PluginUiField has no order key and the adapter drops one if you add it, so uiSchema key order has no effect whatsoever. To move a field, move it in the zod schema.

The default label is the full dotted path

Every leaf renders label = uiSchema.label ?? name, where name is the full dotted form path. A field inside a panel with no label shows translations.viewDetails, not viewDetails. There is no humanisation. Declare label on every field you nest.

Never re-declare a base key

The adapter builds base = { ...commonWidgetUiSchema, width: <legacy panel> } and then, for each top-level key of your uiSchema, does base[key] = convert(value) — a wholesale replacement, not a deep merge. Re-declaring any of these destroys that panel’s layout, its components, its conditions and its order:

spacing · colorScheme · lightModeColors · darkModeColors · font · language · section · customCss · filter · width

Re-declaring filter, for example, loses PlatformSelect, PropertySelect and the hidden feed_id input. Re-declaring spacing loses the compound spacing control. If you genuinely need a custom leaf beside the built-ins, you must re-declare the entire panel and rebuild every entry yourself. A root ui key in a plugin uiSchema is skipped entirely.

Enforcement: advisory (unvalidated) — nothing warns, in the dashboard or at publish.

Layout widgets: the mandatory slots field

A layout widget must declare a slots object in its zod schema, with keys that match the manifest’s declared slot names exactly:

// pdp-suite/src/widgets/pdp-frame/config.ts
export const configZod = widgetSchema.extend({
  stickyRail: z.boolean().default(true).describe("Keep the sidebar rail pinned"),
  slots: z
    .object({
      hero: z.array(z.string()).default([]),
      main: z.array(z.string()).default([]),
      sidebar: z.array(z.string()).default([]),
      bottom: z.array(z.string()).default([]),
    })
    .default({})
    .describe("Widgets bound into each region"),
});

Do not write a uiSchema entry for slots. When the resolved widget has category: "layout" and a non-empty manifest slots[], the dashboard discards whatever you declared and injects its own panel — label Slot Configuration, order: -50, one row group per declared slot titled {name} slot and described accepts: {list}, each row a widget combobox filtered by that slot’s accepts. Empty rows are stripped on save.

Without the zod field there is nowhere to store the bindings, so the customer’s slot picks silently vanish. The stored shape is settings.slots: Record<slotName, widgetId[]>. Everything else about slots, accepts, prefill, presets and page assignment lives in layouts.md.

Not supported yet. Plugin layouts do not inherit slotSpacing or gap — those live on a private internal schema. Declare your own if you want per-slot spacing; the builders in sdk-exports.md (marginBoxSchema, paddingBoxSchema, widthSchema, maxWidthSchema, DEFAULT_SLOT_SPACING) let you rebuild the same shape.

Translations

Declare each translatable string as z.record(z.string(), z.string()) — locale code → text — and pair it with component: "Translations".

// property-picks/src/config.ts — a published plugin
translations: z
  .object({
    viewDetails: z
      .record(z.string(), z.string())
      .default({ en_US: "View details", fr_FR: "Voir les détails", es_ES: "Ver detalles" })
      .describe("View-details CTA"),
    perNight: z
      .record(z.string(), z.string())
      .default({ en_US: "/ night", fr_FR: "/ nuit", es_ES: "/ noche" })
      .describe("Price unit"),
  })
  .default({
    viewDetails: { en_US: "View details", fr_FR: "Voir les détails", es_ES: "Ver detalles" },
    perNight: { en_US: "/ night", fr_FR: "/ nuit", es_ES: "/ noche" },
  })
  .describe("Translations"),

Editor behaviour the customer sees, and you must design around:

  • Edits are held in local state and only reach the form when the customer presses the editor’s own Save button. A customer who edits and navigates away loses the edit.
  • Removing a locale row raises a browser confirm() dialog.
  • The editor derives its i18n key from the last segment of the field path, so translations.viewDetails keys on viewDetails.

At runtime:

// property-picks/src/widget.tsx — a published plugin
const { detectedLocale } = useLocaleDetection(
  options.language.useAutoDetectedLocale,
  options.language.locale,
);
const { _t } = useTranslation(options.translations ?? {}, detectedLocale);
// …
_t("viewDetails", "View details")

_t(key, defaultValue = key) resolves store[key][currentLocale] || store[key]["en_US"] || defaultValue — an empty string falls through, so a blank row is treated as missing. useLocaleDetection seeds state with the configured locale so the server and the client’s first render agree, then switches to the browser locale one tick after mount when auto-detect is on. Both come from @homerunner-next/widget-core/utils.

Not supported yet. The plugin save path runs only the empty-slot stripper, not the blank-row strippers system widgets get — so a locale row the customer blanks is persisted forever as "". It is harmless as long as you always pass a defaultValue to _t and always keep a non-empty en_US entry in your schema default. Never rely on _t(key) alone.

Per-widget sub-manifests

(widget-core 0.11.0+, suites only.) Your project’s scripts/inject-schema.mjs writes one JSON file per widget and stamps the reference back onto the summary:

// packages/create-hr-plugin/template/scripts/inject-schema.mjs — the multi-widget branch
const { configSchema, uiSchema } = await import(pathToFileURL(configPath).href);
const sub = { slug, configSchema };
if (uiSchema) sub.uiSchema = uiSchema;
fs.writeFileSync(subPath, JSON.stringify(sub, null, 2) + "\n");
widget.manifest = `widgets/${slug}.manifest.json`;

That script lives in your project, not in the SDK. Only configSchema and uiSchema are ever read; slug is decorative (pdp-suite’s own copy omits it).

RuleEnforcement
Sub-manifests are committed to git and shipped in both zips. Nothing regenerates them for the reviewer beyond your own build.publish ERROR if referenced and absent
Only the dashboard fetches them, from the CDN, resolved against the plugin’s manifest_url. The renderer never does — it caches the root manifest only.
The fetch is bounded by AbortSignal.timeout(5000) and cached per URL for the tab’s life; a rejected fetch is evicted so the next open retries.silent degrade
A fetch failure, a missing manifest reference, or a sub-manifest with no configSchema all degrade to the schemaless state — the widget still previews from its raw settings blob, but there is no form.silent degrade
A declared sub-manifest missing from the built zip.publish ERROR: {path} ({label}'s sub-manifest) is missing from the zip or invalid JSON.
A sub-manifest present but carrying no configSchema.publish warning: {path} has no configSchema — {label}'s dashboard form will be empty.

Operator-visible strings, so you can recognise a screenshot:

# src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/PlayGround.tsx
Plugin "{slug}" not enabled for this feed.
Widget "{keyword}" is not declared by plugin "{slug}".
Loading plugin manifest...
No configSchema for this widget. Ask the plugin developer to ship one via
zodToManifestSchema(widgetSchema.extend({...})) in the widget's config.ts.

If your JSON Schema is present but the browser cannot rebuild a z.ZodObject from it, the failure is caught, logged as [plugin-playground] jsonSchemaToZod failed: and the same amber “No configSchema” banner appears.

Publish-time schema checks

All four run against the built zip, at publish, and are quoted verbatim above:

CheckEnforcement
Single-widget manifest has no configSchemapublish warning
Suite sub-manifest has no configSchemapublish warning
properties lacks colorScheme or spacingpublish warning
Declared sub-manifest missing or invalid JSONpublish ERROR

The audit also records each widget’s configSchema property keys so the reviewer can diff them against your previous version.

Not supported yet. The built-zip audit runs at publish, on the reviewer’s side. Its warnings are shown to the reviewer, never to you — you can be told your submission is fine and only learn about a schema warning if the reviewer relays it. Nothing in the source-zip audit you run in your browser looks at configSchema at all. See packaging-and-publishing.md and ../ship/preflight-and-submit.md.

Defaults at widget creation

When a customer creates a widget from your plugin, the seed settings are:

  1. Every top-level default in your configSchema, recursing into object-typed properties that have no default of their own.
  2. The feed’s branding, minus colorScheme — so the widget stays on the global inherit placeholder and follows the feed live.
  3. filter.feed_id = <feed id>, always, merged over whatever your schema’s filter default held.

A sub-manifest fetch failure at this point degrades step 1 to {} — the widget is created carrying only the branding and filter.feed_id, and your schema’s own defaults are what parseWidgetConfig supplies at render time.

In the editor the form is seeded with your defaults merged under the stored settings. Because the whole seeded object is autosaved on the first edit, your defaults become explicitly stored values the moment a customer touches anything — a later change to a schema default will not reach widgets that already exist.

See also