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:
| Where | What it decides |
|---|---|
The SDK resolver (resolvePluginWidget) | Which branch a stored keyword resolves through |
| The source-zip audit | Whether widgetType/ssr.url/assets.js are required at the root, or widgets[] is validated instead |
hr-widget-build | Two Vite passes, or a (widget × pass) loop with BUILD_WIDGET set |
viteHomerunnerWidget | A bare vite build on a suite fails fast rather than emitting a flat, broken dist/ |
Your scripts/inject-schema.mjs | Schemas inlined into the root manifest, or written to per-widget sub-manifests |
| The dashboard | One 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 | |
|---|---|---|
| Discriminator | no widgets[] | non-empty widgets[] |
| Widget described by | root widgetType, ssr, assets | one widgets[] entry each |
| Keyword a customer stores | plugin:{slug} | plugin:{slug}:{widget} |
| Config schema lives in | root configSchema / uiSchema | widgets/{slug}.manifest.json, fetched by the dashboard on demand |
| Source tree | src/widget.tsx, src/config.ts, src/index.tsx, src/ssr-entry.ts | src/widgets/{slug}/{widget,config,index}.tsx per widget |
| Built files | dist/{slug}.iife.js, dist/{slug}.css, dist/{slug}-ssr.umd.js | dist/{widget}/{widget}.iife.js, … nested per widget |
| Server rendering | always required — a v1 plugin cannot be CSR-only | per widget: ssr.url, or an explicit ssr: false |
| Layout widgets | impossible | category: "layout" with slots[] and pages[] |
| Presets | not validated, not used | up to 8 one-click compositions |
Per-widget icon, readme, description, expectedHeight, deprecated, mediaSafeAuto, externalFetch, assets.fonts | root-level only, one set for the plugin | per widget |
| Widget picker | one entry, named from the root manifest | one entry per non-deprecated summary, plus preset entries |
| Plugin page | one 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 label | a full section per widget with its own icon, readme, badges, instance count, per-feed toggle and Create button |
| Widgets per version | 1 | up to 24 |
| Minimum SDK | any | widget-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.
| Manifest | Keyword | Result |
|---|---|---|
| v1 | plugin:{slug} | resolves |
| v1 | plugin:{slug}:{anything} | null — v1 manifests have no widget slugs |
| Suite | plugin:{slug} | null — a suite needs a widget segment |
| Suite | plugin:{slug}:{unknown} | null — no summary with that slug |
| Suite | plugin:{slug}:{widget} whose summary has no assets.js | null |
v1 with no root assets.js | plugin:{slug} | null |
| either | a 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 iskeyword 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 fordata-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.jsfor v1,/p/{slug}/{widget}/{widget}.iife.jsfor 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:
| Step | Shape checked? |
|---|---|
| The audit in your browser, before upload | no |
POST /api/v3/plugins/submissions (submit) | no |
| Admin review — approve / changes requested / rejected | no |
| 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.
replacedByexists in the widget summary type and is read by nothing. Inside a suite you can retire a widget withdeprecated: trueand 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 later | no — new slug | yes, in the next version |
| Can add a layout later | no | yes |
| Can add a preset later | no | yes |
| Can ship a CSR-only widget | no | yes |
npx create-hr-plugin generates it | yes — the default, --template single | yes — --template suite (0.8.0+) |
Works with npm run dev (:3001, MSW-mocked) | yes | yes — all widgets on one page, the layout composing the others |
Works with npm run dev:ssr | yes | yes — 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-build | yes | yes |
| Publishes, installs, renders, SSRs | yes | yes |
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
settingsobject 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:ssrrenders 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:
- Scaffold the shape you actually want:
npx create-hr-plugin <slug> --template suite, even if it holds one widget. See Build a suite. - If you started single-widget and changed your mind, convert before you publish —
rewrite
manifest.jsonand movesrc/*undersrc/widgets/{slug}/. Same page. - 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
- Manifest: root fields and Manifest: widget summary — every field, per shape.
- Layout widgets — the capability only a suite can have.
- Anatomy of a plugin — the two source trees side by side.
- Build a suite — the conversion checklist.