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

The two manifest shapes

Every HomeRunner plugin is one of two things, and you choose which on the day you write your first manifest.json:

  • a single-widget plugin (the v1 shape) — one widget, described by root-level fields;
  • a suite (the multi-widget shape) — up to 24 widgets under one version, one review and one publish, each described by an entry in widgets[]. (widget-core 0.11.0+)

Both shapes are supported forever, and neither is deprecated. But the shape is frozen at your first publish: the only way out of the wrong choice is a new plugin slug — a new marketplace listing your existing customers would have to install and re-configure. Most authors take the single-widget shape because it is the scaffold’s default, then discover six months later that they want a second widget, a layout, or a preset. It is one flag either way — decide on purpose. The short answer is in Which should I choose: start as a suite, even with one widget.

The discriminator is one line

// packages/homerunner-widget-core/src/manifest.ts — isMultiWidgetManifest
return Array.isArray(manifest.widgets) && manifest.widgets.length > 0;

That is the whole test. There is no version flag, no type field, no "shape" key. A manifest with a non-empty widgets array is a suite root; anything else — widgets absent, widgets: [], widgets: {} — is a v1 single-widget manifest.

The same predicate is re-implemented at every layer that touches your plugin, which is why one array reconfigures so much at once:

WhereWhat it decides
The SDK resolver (resolvePluginWidget)Which branch a stored keyword resolves through
The source-zip auditWhether widgetType/ssr.url/assets.js are required at the root, or widgets[] is validated instead
hr-widget-buildTwo Vite passes, or a (widget × pass) loop with BUILD_WIDGET set
viteHomerunnerWidgetA bare vite build on a suite fails fast rather than emitting a flat, broken dist/
Your scripts/inject-schema.mjsSchemas inlined into the root manifest, or written to per-widget sub-manifests
The dashboardOne picker entry, or one per non-deprecated summary

Central applies it too, comparing your new manifest against the live one — that is the freeze, and it is the subject of The one-shape rule below.

Side by side

Full field tables live in Manifest: root fields and Manifest: widget summary. This is the difference, not the reference.

Single-widget (v1)Suite
Discriminatorno widgets[]non-empty widgets[]
Widget described byroot widgetType, ssr, assetsone widgets[] entry each
Keyword a customer storesplugin:{slug}plugin:{slug}:{widget}
Config schema lives inroot configSchema / uiSchemawidgets/{slug}.manifest.json, fetched by the dashboard on demand
Source treesrc/widget.tsx, src/config.ts, src/index.tsx, src/ssr-entry.tssrc/widgets/{slug}/{widget,config,index}.tsx per widget
Built filesdist/{slug}.iife.js, dist/{slug}.css, dist/{slug}-ssr.umd.jsdist/{widget}/{widget}.iife.js, … nested per widget
Server renderingalways required — a v1 plugin cannot be CSR-onlyper widget: ssr.url, or an explicit ssr: false
Layout widgetsimpossiblecategory: "layout" with slots[] and pages[]
Presetsnot validated, not usedup to 8 one-click compositions
Per-widget icon, readme, description, expectedHeight, deprecated, mediaSafeAuto, externalFetch, assets.fontsroot-level only, one set for the pluginper widget
Widget pickerone entry, named from the root manifestone entry per non-deprecated summary, plus preset entries
Plugin pageone synthetic card (plugin icon, name, content badge, keyword, description, Create button) — and nothing more: no per-widget README, no SSR/CSR badge, no instance count, no per-feed toggle, no count in the tab labela full section per widget with its own icon, readme, badges, instance count, per-feed toggle and Create button
Widgets per version1up to 24
Minimum SDKanywidget-core 0.11.0+

A v1 root, from the scaffold:

// packages/create-hr-plugin/template/manifest.json (placeholders substituted)
{
  "id": "my-widget",
  "version": "1.0.0",
  "name": "My Widget",
  "widgetType": "my-widget",                 // required here; its VALUE is never read
  "ssr":    { "url": "dist/my-widget-ssr.umd.js" },
  "assets": { "js": "dist/my-widget.iife.js", "css": "dist/my-widget.css" },
  "runtime": { "react": "^19.0.0" }
  // the build injects "configSchema" and "uiSchema" at this level
}

The same plugin as a suite of one — note what moved and what disappeared:

// DERIVED: the manifest above rewritten in the shape of
// pdp-suite (reference plugin): manifest.json — widgets[] with one entry
{
  "id": "my-widget",
  "version": "1.0.0",
  "name": "My Widget",
  "runtime": { "react": "^19.0.0" },
  "widgets": [                               // presence => suite. No widgetType,
    {                                        // no root ssr/assets/configSchema.
      "slug": "hero",
      "name": "Hero",
      "ssr":    { "url": "dist/hero/hero-ssr.umd.js" },
      "assets": { "js": "dist/hero/hero.iife.js", "css": "dist/hero/hero.css" },
      "manifest": "widgets/hero.manifest.json"
    }
  ]
}

Every customer of the first manifest stores the keyword plugin:my-widget. Every customer of the second stores plugin:my-widget:hero. Those two strings are the whole reason the shape can never change.

Mixing the shapes is not an error

Mutual exclusivity is convention, not enforcement. A manifest that carries both widgets[] and the v1 root fields passes every validator: resolution takes the suite branch, silently ignores root widgetType / ssr / assets / configSchema, and the bare plugin:{slug} keyword stops resolving. Nothing tells you. Delete the root fields when you convert.

Shape mismatches fail closed

Resolution never guesses. When the stored keyword and the manifest disagree, the resolver returns null and the widget is removed — it is never rendered with defaults, and never falls back to another widget.

ManifestKeywordResult
v1plugin:{slug}resolves
v1plugin:{slug}:{anything}null — v1 manifests have no widget slugs
Suiteplugin:{slug}null — a suite needs a widget segment
Suiteplugin:{slug}:{unknown}null — no summary with that slug
Suiteplugin:{slug}:{widget} whose summary has no assets.jsnull
v1 with no root assets.jsplugin:{slug}null
eithera malformed keyword (plugin:, plugin::hero, plugin:acme:)null — the keyword does not even parse

Keyword grammar is specified in Keywords, assets and URLs.

A null looks different on each of the three paths a plugin reaches a customer through:

  • Server-rendered pages. The renderer replaces the widget with a hidden breadcrumb node carrying data-hr-widget-error="{keyword}" and an HTML comment whose reason is keyword does not resolve against the plugin manifest. The rest of the page renders normally, and the page is flagged degraded so it caches for a much shorter window. View-source and search for data-hr-widget-error. The full reason index is in Limits and error index.
  • CSR embeds. Nothing errors, because nothing runs. The copied snippet’s script tag is derived from the keyword — /p/{slug}/{slug}.iife.js for v1, /p/{slug}/{widget}/{widget}.iife.js for a suite — and the proxy serves only files the live manifest declares, so a stale embed gets {"error":"Asset not in plugin manifest"} and the host <div> is left untouched.
  • The dashboard. A keyword that no longer resolves against the manifest yields no schema, so the customer’s config form degrades to a schemaless panel rather than erroring.

Every one of those is silent from your side. Nobody is notified — not the customer, not you.

The one-shape rule

Before your first publish you can convert freely. The gate compares your new manifest against the plugin’s live one, and an unpublished plugin has none. Flip as often as you like while you are building.

After your first publish the shape is permanent. Central refuses a manifest that flips it with 422 MANIFEST_SHAPE_CHANGED, on publish and on rollback, so you cannot even roll back across a shape change. The dashboard mirrors the same check just before the reviewer uploads the built zip. Both messages are quoted verbatim in Limits and error index.

The trap is when it fires:

StepShape checked?
The audit in your browser, before uploadno
POST /api/v3/plugins/submissions (submit)no
Admin review — approve / changes requested / rejectedno
Publish (and rollback)yes — 422 MANIFEST_SHAPE_CHANGED

So a shape flip passes your own audit, passes submit, and passes review. You are told the submission looks fine, and days later the publish dies. Do not read “the reviewer approved it” as “the shape is acceptable”.

The reason is a serving contract, not a policy: customers’ widget rows store the keyword, customers’ web pages contain copied embed URLs built from that keyword, and neither survives a shape change. Both would resolve to null on the very next render, across every feed at once.

The only remedy is a new plugin slug. manifest.id is the slug, so a new shape means a new id, a new plugin row, a new marketplace listing and a new CDN prefix. Your existing customers keep running the old plugin, and reach the new one only by installing it and re-creating their widgets from it.

Not supported yet. There is no migration path, no alias, no “this plugin replaces that plugin” pointer. replacedBy exists in the widget summary type and is read by nothing. Inside a suite you can retire a widget with deprecated: true and keep every placement rendering — see Manifest: widget summary. There is no equivalent for retiring a whole shape.

Which should I choose

Start as a suite, even with one widget. A suite of one is legal, publishes normally, and costs you nothing you cannot get back. Choosing v1 costs you a decision you cannot revisit.

Single-widget (v1)Suite of one
Can add a second widget laterno — new slugyes, in the next version
Can add a layout laternoyes
Can add a preset laternoyes
Can ship a CSR-only widgetnoyes
npx create-hr-plugin generates ityes — the default, --template singleyes — --template suite (0.8.0+)
Works with npm run dev (:3001, MSW-mocked)yesyes — all widgets on one page, the layout composing the others
Works with npm run dev:ssryesyes — one named widget at a time (widget-core 0.12.2+, and create-hr-plugin 0.8.1+ for the script)
npm run build / hr-widget-buildyesyes
Publishes, installs, renders, SSRsyesyes

A suite used to cost you the dev tooling. It no longer does: --template suite generates a suite-aware sandbox (create-hr-plugin 0.8.0+) and a dev:ssr script (0.8.1+), and nothing in the build was ever single-widget-shaped — hr-widget-build branches on widgets[] itself, and the scaffold’s vite.config.ts and scripts/inject-schema.mjs handle both shapes with no edits. Two limits are left, and both are local-only:

Not supported yet. The dev sandbox holds one mock state for the whole page — scenario, latency, branding and the widget settings object are a page singleton, so two widgets with the same field name share its value. And there is no server-side slot composition locally: npm run dev:ssr renders the one widget you named, so a layout previews with empty slots. Details in Dev sandbox and mocking and Previewing SSR locally.

So the recommended path is now the short one:

  1. Scaffold the shape you actually want: npx create-hr-plugin <slug> --template suite, even if it holds one widget. See Build a suite.
  2. If you started single-widget and changed your mind, convert before you publish — rewrite manifest.json and move src/* under src/widgets/{slug}/. Same page.
  3. Publish. From here the shape is fixed, but you can add widgets, a layout and presets in any later version.

Choose v1 deliberately only when all of these are true: the plugin is one widget, it will always be one widget, it server-renders, and you want the single-widget template’s Tailwind and src/components/ui/ kit more than you want the option to grow. A marketing badge or a one-off embed is a fair v1. Anything you expect to iterate on is a suite.

Suites need @homerunner-next/widget-core 0.11.0+ (layouts and presets too; assets.fonts and pages need 0.12.0+, slot prefill 0.12.1+, a suite’s npm run dev:ssr 0.12.2+), and the suite scaffold itself needs create-hr-plugin 0.8.0+ — 0.8.1+ for the dev:ssr script it writes. What npm currently serves, and how to get a newer SDK, is in Install and versions.

See also