Manifest: root fields
manifest.json sits at the root of your project, next to package.json. It is the only
file the platform reads to learn that your plugin exists, what it ships, and how to serve
it. It travels in both zips, is cached by the platform, and is handed to the renderer on
every server render.
There are two shapes. A manifest is the suite (multi-widget) root if and only if
widgets is a non-empty array; otherwise it is the single-widget (v1) shape. Nothing
else distinguishes them — there is no version flag and no type field. See
The two manifest shapes for how to choose, and
Manifest: widget summary for every field inside a widgets[] entry.
This page is normative for every ROOT-level field. Per-widget fields live in widget-summary.md; layout semantics in layouts.md; every hard number and the full error index in limits-and-errors.md.
Where root fields are enforced
Three checkpoints read your root manifest. This page tags each rule with the one that catches it, because they happen days apart.
| Tag | Checkpoint | Who sees it |
|---|---|---|
| publish ERROR (audit) | The dashboard audits your source zip in your browser, before a byte is uploaded. | You, immediately. |
| publish ERROR (submit) | POST /api/v3/plugins/submissions re-validates the manifest server-side. | You, as a 422 on Upload. |
| publish ERROR (build) | The built-zip audit plus central’s publish/rollback gates, run by the reviewer after approval. | The reviewer. You learn only that publishing failed. |
publish warning never blocks anything; warnings raised on the built zip are visible only
to the reviewer. Enforcement-level vocabulary is defined once in
limits-and-errors.md.
Always required (both shapes)
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
id | string | Both | The plugin slug. /^[a-z0-9][a-z0-9-]*$/, ≤ 255 chars. Used verbatim — it is not slugified for you. | publish ERROR (audit): manifest "id" ("My_Plugin") must be lowercase alphanumeric with hyphens — it is the plugin's slug. · missing → manifest.json is missing the required "id" field. |
version | string | Both | Three-part MAJOR.MINOR.PATCH, ≤ 64 chars. Strictly greater than your published version. | publish ERROR (audit): manifest "version" ("1.2") is not valid semver (e.g. 1.2.0). · publish ERROR (submit): 422 INVALID_VERSION, 422 VERSION_NOT_GREATER |
name | string | Both | Non-empty, ≤ 255 chars. Display name in the marketplace and the widget picker. | publish ERROR (audit): manifest.json is missing the required "name" field. |
runtime.react | string | Both | A semver range, e.g. "^19.0.0". Required by the PluginManifest type, and every real manifest carries it — but it is declarative metadata only (see below). | advisory (unvalidated): no checkpoint reads or requires it. Ship it anyway; a reviewer looks at it. |
Required in the single-widget shape only
Present these three when widgets is absent or empty. A suite root must not carry
them.
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
widgetType | string | Single-widget | Non-empty string. Its value is never read by anything — routing uses plugin:{id}. The scaffold sets it to your id; keep doing that. | publish ERROR (audit): manifest.json is missing the required "widgetType" field. |
ssr.url | string | Single-widget | Path to the SSR bundle, e.g. dist/my-widget-ssr.umd.js. A v1 plugin has no CSR-only mode — there is no ssr: false at the root. | publish ERROR (audit): manifest.json is missing "ssr.url" (the SSR bundle path). · at publish: manifest is missing "ssr.url". |
assets.js | string | Single-widget | Path to the client IIFE. A v1 manifest with no assets.js resolves to null and the widget never renders. | publish ERROR (audit): manifest.json is missing "assets.js" (the client bundle path). · at publish: manifest is missing "assets.js". |
assets.css | string | No | Optional in the type and in both audits. | — |
assets.fonts | string[] | No | Max 8; absolute http(s) URLs or paths confined to the version prefix. (widget-core 0.12.0+) Honoured at resolve for v1 roots and validated by central, but not by the browser audit. See assets-and-urls.md. | publish ERROR (submit) only |
configSchema | JSON Schema | No | v1 only. Generated by your build from zodToManifestSchema(configZod) — never hand-edited. In a suite these move to per-widget sub-manifests. | publish warning (build): manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be empty. |
uiSchema | object | No | v1 only, same as above. See config-and-ui-schema.md. | — |
Two more root fields are read for v1 manifests but not declared in the
PluginManifest TypeScript type, so your editor will not offer them: root-level
mediaSafeAuto (strict true) and root-level externalFetch (string[]). Both resolve
exactly like their widgets[] counterparts and neither is validated at publish. In a
suite, declare them per widget.
Not supported yet. A v1 root has no
category, noslots, nopagesand noexpectedHeight. A single-widget plugin can never be a layout and can never be CSR-only. Both need the suite shape.
Optional everywhere
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
description | string | No | Optional. Shown on the marketplace card. String, ≤ 4000 chars if present. | publish ERROR (submit): 422 INVALID_MANIFEST Manifest "description" must be a string of at most 4000 characters. |
author | {name, url?} | No | Both members are strings ≤ 255 chars. author.url renders only when it matches ^https?://. | publish ERROR (submit): Manifest "author.name" must be a string of at most 255 characters. |
homepage | string | No | Rendered on the plugin page only when it matches /^https?:\/\//i. Anything else is silently dropped. | advisory (unvalidated) |
support | string | No | Same rule as homepage. | advisory (unvalidated) |
docs | string | No | Same rule as homepage. | advisory (unvalidated) |
license | string | No | Free text (e.g. "MIT"). Nothing parses it. | advisory (unvalidated) |
tags | string[] | No | Non-string entries are dropped; the list is truncated to 12 when displayed. | silent runtime truncation |
screenshots | string[] | No | media/<file> paths only — an absolute URL is rejected. Each file must ship in the built zip. No count cap. | publish ERROR (submit + build), see Media references |
icon | string | No | media/<file> (flat) or an absolute http(s) URL, ≤ 2048 chars. (widget-core 0.12.0+) | publish ERROR (submit + build) |
cover | string | No | Same value rules as icon. (widget-core 0.12.0+) | publish ERROR (submit + build) |
readme | string | No | media/<file>.md or media/<dir>/<file>.md, ≤ 255 chars, file ≤ 64 KB. (widget-core 0.12.1+) | publish ERROR (submit + build) |
widgets | PluginWidgetSummary[] | No | Presence flips the manifest to the suite shape. Max 24 entries. (widget-core 0.11.0+) See widget-summary.md. | publish ERROR (audit): manifest declares 26 widgets — the limit is 24 per plugin. |
presets | Preset[] | No | Max 8. Suite shape only. (widget-core 0.11.0+) See Presets. | publish ERROR (audit + submit) |
shadowDOM | boolean | No | Whole-plugin flag. (widget-core 0.10.1+) See shadowDOM. | advisory (unvalidated), but see the stranding failure below |
runtime.widgetCore | string | No in the type, required to publish | Stamped by the build. Never hand-write it. Must be ≥ 0.10.0. | publish ERROR (build), see runtime |
A media/CHANGELOG.md in your zip becomes the plugin page’s Changelog tab by
convention — it is not declared anywhere in the manifest.
Not supported yet. There is no root-level
keywords,minPlatformVersion,permissionsordependenciesfield. Unknown root keys are ignored everywhere, so a typo like"screenshot"fails silently rather than erroring.
id
manifest.id is the plugin slug. Central stores it verbatim after checking
/^[a-z0-9][a-z0-9-]*$/; there is no Str::slug normalisation, so My_Plugin is
rejected rather than repaired.
The slug is permanent and globally unique across all authors:
- An existing row owned by someone else → 409
SLUG_TAKEN,The plugin id "acme-suite" already belongs to another author. - An existing row with no owner (a legacy admin-registered plugin) → 409
SLUG_RESERVED,The plugin id "acme-suite" is reserved. Contact an administrator to claim it.
It also constrains your upload key: the submission object must live under
submissions/{id}/…, else 422 INVALID_ZIP_KEY. Full submit flow in
ship/preflight-and-submit.md.
Your id is the first half of every keyword the platform stores against a customer’s
widget row (plugin:{id} or plugin:{id}:{widget}), which is why it can never change.
version
Three hard rules, in the order you will hit them:
- Shape, in the browser.
/^\d+\.\d+\.\d+(-…)?(\+…)?$/.1.2fails withmanifest "version" ("1.2") is not valid semver (e.g. 1.2.0). - Three parts, at central. Composer’s parser would accept
1.0; central re-checks with its own regex and returns 422INVALID_VERSION,Manifest "version" must be MAJOR.MINOR.PATCH semver: 1.0. - Strictly greater, at central. 422
VERSION_NOT_GREATER,Version 1.2.0 must be greater than the published 1.3.0.
One version number covers the whole suite. There is no per-widget version.
Published version prefixes on the CDN are immutable — re-publishing the same version is refused. See ship/publish-versions-and-rollback.md.
Not supported yet. Pre-release suffixes (
1.0.0-rc.1) pass every validator, but each immutability detector in the fleet requires a bareX.Y.Zpath segment. A pre-release publish silently loses direct content-addressed asset URLs, the life-of-instance build-manifest cache and the slug-keyed SSR bundle cache, falling back to short-TTL proxied serving everywhere. Ship plainX.Y.Zversions.
runtime
// packages/create-hr-plugin/template/manifest.json — the build adds `widgetCore`
{
"runtime": { "react": "^19.0.0" }
}
After a build:
// /Users/.../hr-plugins/pdp-suite/manifest.json (real, published 1.3.3)
{
"runtime": { "react": "^19.0.0", "widgetCore": "0.12.0" }
}
runtime.react is declarative metadata. Nothing in the renderer, the dashboard, the
asset proxy or central reads its value, and no checkpoint requires it to be present — the
build’s stamp pass creates the runtime object for you if it is missing. It documents
intent for a reviewer; it does not gate anything. (The old guide claimed “the renderer
rejects plugins whose runtime.react doesn’t match”. There is no such code.)
What actually controls which React you run against is the exact pin set in your
package.json, because the platform externalises React and hands you its own copy at
runtime. Patch drift trips react-dom’s own Incompatible React versions assertion. The
canonical pins live in
get-started/01-install-and-versions.md.
runtime.widgetCore is the enforced gate. It records the exact
@homerunner-next/widget-core version your build ran with, and it is written for you by
the build’s finalize pass — treat it as generated output, not a field you own. Expect
manifest.json to come back dirty in git after npm run build; commit it.
| Rule | Enforcement | Message |
|---|---|---|
| Present and semver | publish ERROR (build) | manifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime. Update @homerunner-next/widget-core to 0.10.0+ and rebuild. |
≥ 0.10.0 | publish ERROR (build) | Built against widget-core 0.9.0 — the fleet requires 0.10.0+ (older builds bundle a stale mount that breaks on SSR pages). Update and rebuild. |
Bare X.Y.Z at central | publish ERROR (build) | 422 MANIFEST_WIDGET_CORE_MISSING — central’s regex is /^\d+\.\d+\.\d+$/, so a pre-release stamp is rejected even though the dashboard audit accepts one. |
The same gate runs on rollback, so you cannot roll back to a version built with a pre-0.10.0 SDK.
shadowDOM
// /Users/.../hr-plugins/pdp-suite/manifest.json
{ "shadowDOM": true }
A root-level flag governing the whole plugin — there is no per-widget override. Omit
it (or set true) unless every one of your client bootstraps passes
shadowDOM: false to mount().
It buys one thing: Declarative Shadow DOM for your server-rendered markup, which means your widget is styled at parse time with no mount blink. The renderer grants DSD only when both hold:
manifest.shadowDOM !== false, andruntime.widgetCoreparses to ≥0.10.0.
Nothing validates that the flag agrees with your bootstrap. When they disagree — the
manifest says shadow, mount() says none — the server attaches a shadow root that your
mount never adopts, and the SSR content is stranded: visible in view-source, invisible on
screen. See runtime-and-mount.md for the mount side and
recipes/styling-and-theming.md for the styling
consequences.
Media references
Four fields point at files under media/. Their value rules are not the same, and
the differences are load-bearing.
| Field | Accepts a media/ path | Accepts an absolute URL | Nesting | Length cap |
|---|---|---|---|---|
icon | yes, flat media/<file> | yes | none | ≤ 2048 chars |
cover | yes, flat media/<file> | yes | none | ≤ 2048 chars |
widgets[].icon | yes, flat media/<file> | yes | none | ≤ 2048 chars |
screenshots[] | yes, flat media/<file> | no — rejected | none | — |
readme, widgets[].readme | yes, .md only | no | one directory level | ≤ 255 chars path, ≤ 64 KB file |
Exact patterns, from the SDK:
// packages/homerunner-widget-core/src/manifest.ts (isPluginMediaRef, isPluginReadmeRef)
/^media\/[^/\\]+$/ // icon, cover, widgets[].icon — flat, no subdirectory
/^https?:\/\/[^\s]+$/i // …or an absolute URL, for those three only
/^media\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?\.md$/ // readme — one optional subdir
Violations:
| Condition | Enforcement | Message |
|---|---|---|
icon/cover is neither shape | publish ERROR (build) | manifest "icon" must be a "media/<file>" path or an absolute http(s) URL. |
A declared media/ icon is absent from the zip | publish ERROR (build) | Declared icon media/logo.svg is missing from the zip. |
A screenshot is not media/<file> | publish ERROR (build) | manifest screenshot "https://cdn.example.com/1.png" must be a "media/<file>" path. |
| A declared screenshot is absent | publish ERROR (build) | Declared screenshot media/1.png is missing from the zip. |
readme is the wrong shape | publish ERROR (build) | manifest "readme" must be a "media/<file>.md" (or "media/<dir>/<file>.md") path. |
| A declared README is absent | publish ERROR (build) | Declared manifest "readme" media/README.md is missing from the zip. |
| A README exceeds 64 KB | publish ERROR (build) | manifest "readme" media/README.md is 71 KB — the limit is 64 KB. |
Central re-checks the same shapes at submit and returns 422 INVALID_MANIFEST with its
own wording, e.g. Manifest "icon" must be a "media/<file>" path or an absolute http(s) URL (max 2048 characters).
Three consequences worth internalising:
media/is authored content. Nothing generates it, and the reviewer only runs your build. A declared icon, screenshot or README that is not committed to your source zip makes the publish fail after you were told the submission looked fine. See packaging-and-publishing.md.- A
media/reference resolves only after your first publish. It is resolved withnew URL(ref, manifest_url), and an unpublished plugin has nomanifest_url— so the resolver returnsnulland the icon falls back to a glyph. An absolute URL renders immediately, including in the review console. If you want your icon visible during first review, use a URL; if you want it version-immutable, usemedia/. - Screenshots asymmetry is deliberate. Screenshot files publish under the same
immutable version prefix as your bundles, so they may not point off-platform. The
plugin page additionally drops any screenshot that is not
media/…, and renders none at all before the first publish.
READMEs are rendered through a markdown sanitizer — no raw HTML, no scripts.
Presets
// /Users/.../hr-plugins/pdp-suite/manifest.json (root level, beside "widgets")
"presets": [
{
"name": "PDP Starter",
"layout": "pdp-frame",
"slots": {
"hero": ["stay-hero", "property-title", "gallery"],
"main": ["stay-facts", "description", "amenities", "calendar"],
"sidebar": ["booking", "booking-cta"],
"bottom": ["reviews"]
}
}
]
presets?: Array<{ name: string; layout: string; slots: Record<string, string[]> }> —
a root field, suite shape only (widget-core 0.11.0+). Presets on a v1 manifest are
neither validated nor used, because a preset must reference a declared layout widget and
only suite roots declare widgets.
Referential integrity is checked at publish, in both the browser audit and central:
| Rule | Enforcement | Message (browser audit) |
|---|---|---|
| At most 8 entries, and a list | publish ERROR (audit) | manifest "presets" must be a list of at most 8 entries. |
Every preset has a name | publish ERROR (audit) | each manifest preset needs a "name". |
layout names a declared widget with category: "layout" | publish ERROR (audit) | preset "PDP Starter" must reference a declared layout widget. |
slots is an object of arrays of strings | publish ERROR (audit) | preset "PDP Starter" needs a "slots" object. / preset "PDP Starter" has an invalid slot binding shape. |
Every slot child is one of this plugin’s own non-layout widget slugs, or a bare system widget keyword /^[a-z][a-z0-9-]{0,63}$/ | publish ERROR (audit) | preset "PDP Starter" references "plugin:acme:hero" which is neither a declared content widget nor a system widget keyword. |
A plugin: reference is never accepted as a slot child — name your own widget by its
bare slug, and a system widget by its bare keyword.
Not supported yet. Preset slot keys are never checked against the layout’s declared slot names. A typo (
"sidebar"vs"side-bar") passes every validator and creates a binding the layout never renders. Copy the slot names from your ownslots[]and diff them by eye.
What one click on a preset actually creates — which children are created versus reused, the naming, the rollback on failure — is in layouts.md and recipes/composing-a-page.md.
Suite roots and v1 roots are not mixable
Mutual exclusivity is convention, not enforcement. If a manifest carries both
widgets[] and the v1 root fields, resolution takes the multi branch, silently ignores
root widgetType / ssr / assets / configSchema, and the bare plugin:{id} keyword
stops resolving — every existing v1 placement breaks. Conversely, a widget slug resolved
against a v1 manifest returns null.
Both failures are fail-closed: the widget is removed from the page and the renderer
records the reason keyword does not resolve against the plugin manifest. The resolution
table and the failure node it produces are in
concepts/manifest-shapes.md.
The shape is frozen at first publish. Central refuses a manifest that flips shape with
422 MANIFEST_SHAPE_CHANGED:
This manifest changes the plugin between single-widget and multi-widget shapes. Existing
widget placements and embed URLs would stop resolving — publish the new shape under a new
plugin slug instead.
Note when this fires: submit and review do not check the shape. The gate runs at publish and at rollback only, and the dashboard mirrors it just before the reviewer uploads your built zip with:
This build changes {slug} between single-widget and multi-widget shapes. Existing
placements and embed URLs would stop resolving — publish the new shape under a new plugin
slug.
So a shape flip will pass your upload and pass review, and die at publish. The only remedy is a new plugin slug. If you might ever ship a second widget, start as a suite — a suite of one is legal.
How the manifest reaches the renderer
- At publish, central stores your root manifest in
manifest_cacheand recordsmanifest_url(the CDN URL of that exactmanifest.json) andcurrent_version. - The renderer asks central for a feed’s widgets. For every widget row whose keyword
starts with
plugin:, the join attaches two fields:
// what the renderer receives per widget row (suite keyword form)
{
"id": "…",
"keyword": "plugin:pdp-suite:stay-hero",
"plugin_manifest": { /* your ROOT manifest.json, verbatim */ },
"plugin_manifest_url": "https://plugins.homerunner.io/prod/pdp-suite/1.3.3/manifest.json"
}
The v1 keyword form is plugin:{id} with no widget segment.
- The renderer parses the keyword, resolves it against
plugin_manifest, and reads the resolved widget’sssr.urlandassets.*.
Four properties of that pipeline you should design around:
- Only the ROOT manifest is cached and shipped. A suite root deliberately carries no
configSchema/uiSchema— those live in per-widget sub-manifests fetched on demand by the dashboard alone. Keep the root small; it rides along with every render. - The manifest attached is the kill-switch-filtered one. Suspended plugins get no manifest at all, and widget summaries switched off platform-wide or on that feed are stripped before the renderer ever sees them. See ship/install-and-kill-switches.md.
- Path-only asset values resolve against
plugin_manifest_url, preserving subpaths.dist/stay-hero/stay-hero.iife.jsagainsthttps://…/prod/pdp-suite/1.3.3/manifest.jsonbecomeshttps://…/prod/pdp-suite/1.3.3/dist/stay-hero/stay-hero.iife.js. Ship path-only values; absolute URLs bypass the whole mechanism and are a legacy shape. - The hashed build manifest is preferred over the proxy. For a pipeline-published
plugin the renderer reads your
dist/manifest.jsonand emits the content-addressed file directly, so a cached page always loads the exact bundle its HTML was rendered with. The version-less/p/{slug}/{file}proxy is the fallback. Both are documented in assets-and-urls.md.
Full example — a real suite root
// /Users/.../hr-plugins/pdp-suite/manifest.json (published v1.3.3), widgets[] trimmed
{
"id": "pdp-suite", // the slug; permanent, globally unique
"version": "1.3.3", // MAJOR.MINOR.PATCH, strictly increasing
"name": "PDP Suite",
"description": "A remix of the platform's property-details layout…",
"readme": "media/README.md", // one nesting level allowed; <= 64 KB
"author": { "name": "HomeRunner QA", "url": "https://homerunner.io" },
"license": "MIT",
"tags": ["pdp", "layout", "property"], // first 12 shown
"runtime": {
"react": "^19.0.0", // metadata only — nothing reads it
"widgetCore": "0.12.0" // STAMPED BY THE BUILD. Never hand-write.
},
"shadowDOM": true, // whole-plugin; must match every mount() call
"widgets": [ // presence => suite shape. Max 24.
{ "slug": "pdp-frame", "name": "PDP Frame", "category": "layout", "…": "…" },
{ "slug": "stay-hero", "name": "Stay Hero", "…": "…" },
{ "slug": "stay-facts", "name": "Stay Facts", "ssr": false, "…": "…" },
{ "slug": "booking-cta","name": "Booking CTA","ssr": false, "…": "…" }
],
// Absolute URLs: these render in the marketplace and the review console BEFORE the
// first publish. `media/…` values would resolve to null until manifest_url exists.
"icon": "https://picsum.photos/seed/pdp-suite-icon/256/256",
"cover": "https://picsum.photos/seed/pdp-suite-cover/1200/400",
"presets": [ // max 8; slot children are own slugs or
{ // bare system keywords, never plugin: refs
"name": "PDP Starter",
"layout": "pdp-frame",
"slots": {
"hero": ["stay-hero", "property-title", "gallery"],
"main": ["stay-facts", "description", "amenities", "calendar"],
"sidebar": ["booking", "booking-cta"],
"bottom": ["reviews"]
}
}
]
}
Every field inside widgets[] is specified in widget-summary.md.
Full example — a v1 root
// packages/create-hr-plugin/template/manifest.json (placeholders substituted)
{
"id": "my-widget",
"version": "1.0.0",
"name": "My Widget",
"description": "A HomeRunner widget plugin",
"author": { "name": "Plugin Author" },
"widgetType": "my-widget", // required; 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" // optional
},
"runtime": { "react": "^19.0.0" } // build adds "widgetCore"
// build also injects "configSchema" and "uiSchema" here
}
See also
- Manifest: widget summary — every
widgets[]field. - Layout widgets —
category,slots,prefill,pages, presets at creation time. - Config schema and UI schema — what goes in
configSchema. - Keywords, assets and URLs — how
assets.*values become URLs. - Packaging and publishing rules — what must be in each zip.
- Limits and error index — every number, every message.